mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent pages expose identity files, reporting relationships, and
runtime settings.
> - Several controls had inconsistent spacing, weak navigation, or
misleading save feedback.
> - Empty task-row detail slots also reduced the title width before
truncation.
> - This pull request fixes those behaviors and aligns the reviewed UI
details.
> - The result is clearer agent editing and more readable task and agent
lists.
## Linked Issues or Issue Description
**What happened?**
Agent headers and file controls had inconsistent layout. The Identity
footer appeared without edits and did not provide clear save
confirmation. File names could wrap or omit the Markdown suffix. Task
titles truncated while empty detail slots occupied the row.
**Expected behavior**
Keep headers and actions aligned. Reveal the Identity footer only for
changes or save feedback. Use valid Markdown file names and actual file
dates. Let titles use available space.
**Steps to reproduce**
1. Open an agent overview, then its Identity tab.
2. Edit, undo, save, copy, and add an instruction file.
3. Resize the file panel and inspect file names and date labels.
4. Hover an agent row and inspect the Chat action and tooltip.
5. Open a task list with no recovery or external details and inspect
title width.
Related: #18716 is the prior UI bundle. #16934 and #14483 concern role
editing; this change does not add a role picker.
## What Changed
- Refine the character header, inline title and name editing, crown
position, runtime shortcut, and Chat/New task actions.
- Align profile metadata and manager selection; hide empty direct
reports.
- Refine Identity panels, Markdown filename validation, file-row hover,
truncation, Paper Mono typography, and copy feedback.
- Add a viewport-bound save footer with dirty, saving, and saved states.
Keep undo/discard separate from successful saves.
- Return recorded creation or modification dates for instruction files.
Reuse the active file transaction for metadata reads to avoid exhausting
the small connection pool.
- Reveal the agent-row Chat icon and label on hover, with a standard
delayed tooltip.
- Collapse empty task-row details so titles can use the available width.
## Verification
- Browser checks covered the local overview and Identity flows, narrow
file panels, successful file creation/update, undo/discard, header
navigation, row hover, tooltip delayed-open state, and full task titles.
- Focused instruction, save-footer, date, font-asset, and task-row tests
passed during development. After review, 55 file/instruction tests
passed, including cold restoration with recorded and unknown dates;
server typecheck and 18 immediate-save response tests also passed.
- Full `pnpm -r typecheck` and `pnpm build` passed after rebase onto
current master. The complete `pnpm test:run` attempt hit a 15-second
timeout in the existing chat Retry HTTP integration test; that case
passed in isolation (1 test passed). A second full attempt hit timeouts
in two other chat integration cases (GitHub webhook draining and
delivery retry budget); both passed in isolation without increasing
timeouts. All 55 remote checks passed on f31405a730, including the full
test matrix, build, typecheck, and final verification gate.
- `pnpm check:token-gates`, `node scripts/check-maps.mjs`, and `git diff
--check` passed on the submitted revision.
## Risks
- File dates are optional. Managed files require recorded dates because
restored projections have synthetic filesystem timestamps. External
files can use modification time; unknown dates are omitted.
- Save-footer timing depends on animation completion; reduced-motion and
failed-save paths have tests.
- Global Paper Mono typography can change text widths. Browser checks
covered the edited surfaces.
- No schema migration or new execution permissions are introduced.
## Model Used
OpenAI Codex, GPT-6, with repository tools, code execution, and browser
verification. The exact serving model ID and context-window size are not
exposed in this session.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes:` / `Closes:`
/ `Refs:` OR (b) described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub references)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run the relevant tests locally and they pass; full-run
timeout limitations and successful isolated reruns are documented above
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
434 lines
18 KiB
TypeScript
434 lines
18 KiB
TypeScript
import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js";
|
|
import { createHash } from "node:crypto";
|
|
import { adoptAgentFiles, agentFileToken, isAgentFileSaveRecorded } from "./agent-file-store.js";
|
|
import { invalidateAgentFileProjection, persistDurableAgentFiles } from "./agent-file-durability.js";
|
|
import { and, desc, eq, getTableColumns, lt, or, sql } from "drizzle-orm";
|
|
import {
|
|
activityLog,
|
|
agents,
|
|
plugins,
|
|
pluginManagedResources,
|
|
agentInstructionHeads as heads,
|
|
agentInstructionRevisions as revisions,
|
|
fileStorageDb,
|
|
type Db,
|
|
} from "@paperclipai/db";
|
|
import type {
|
|
AgentInstructionCommitReceipt,
|
|
AgentInstructionDiff,
|
|
AgentInstructionHistory,
|
|
AgentInstructionSnapshot,
|
|
AgentInstructionSource,
|
|
} from "@paperclipai/shared";
|
|
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
|
import {
|
|
agentInstructionsBundleMode,
|
|
deriveBundleState,
|
|
resolveManagedInstructionsRoot,
|
|
} from "./agent-instructions.js";
|
|
import {
|
|
instructionBytes,
|
|
instructionPath,
|
|
assertInstructionPathSafe,
|
|
readInstructionBytes,
|
|
materializeInstructionBytes,
|
|
} from "./agent-instruction-files.js";
|
|
import {
|
|
authorizeInstructionCommit,
|
|
authorizeInstructionRead,
|
|
} from "./agent-instruction-authorization.js";
|
|
import type { AuthorizationActor } from "./authorization.js";
|
|
|
|
type Tx = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
|
type Revision = typeof revisions.$inferSelect;
|
|
type PluginResetActor = { type: "plugin"; pluginId: string; pluginKey: string; agentKey: string };
|
|
type RevisionActor = AuthorizationActor | PluginResetActor;
|
|
const { contentBase64: _contentColumn, ...revisionMetadataColumns } =
|
|
getTableColumns(revisions);
|
|
export type InstructionTarget = { companyId: string; agentId: string };
|
|
export type InstructionCommitInput = InstructionTarget & {
|
|
entryFile: string;
|
|
content: string | Uint8Array;
|
|
baseRevisionId: string | null;
|
|
source: Exclude<AgentInstructionSource, "seed" | "restore">;
|
|
};
|
|
function metadata(row: Omit<Revision, "contentBase64">) {
|
|
const { createdAt, source, ...fields } = row;
|
|
return {
|
|
...fields,
|
|
source: source as AgentInstructionSource,
|
|
createdAt: createdAt.toISOString(),
|
|
};
|
|
}
|
|
function snapshot(row: Revision): AgentInstructionSnapshot {
|
|
const { contentBase64, ...fields } = row;
|
|
return {
|
|
revision: metadata(fields),
|
|
content: Buffer.from(contentBase64, "base64").toString("utf8"),
|
|
};
|
|
}
|
|
function owner(target: InstructionTarget, entryFile: string) {
|
|
return and(
|
|
eq(revisions.companyId, target.companyId),
|
|
eq(revisions.agentId, target.agentId),
|
|
eq(revisions.entryFile, entryFile),
|
|
);
|
|
}
|
|
function headOwner(target: InstructionTarget, entryFile: string) {
|
|
return and(
|
|
eq(heads.companyId, target.companyId),
|
|
eq(heads.agentId, target.agentId),
|
|
eq(heads.entryFile, entryFile),
|
|
);
|
|
}
|
|
|
|
export function agentInstructionRevisionService(db: Db) {
|
|
db = fileStorageDb(db);
|
|
async function lockTarget(
|
|
tx: Tx,
|
|
target: InstructionTarget,
|
|
actor?: AuthorizationActor,
|
|
) {
|
|
if (actor?.type === "agent" && actor.companyId !== target.companyId)
|
|
throw notFound("Agent not found");
|
|
const [agent] = await tx
|
|
.select()
|
|
.from(agents)
|
|
.where(
|
|
and(
|
|
eq(agents.id, target.agentId),
|
|
eq(agents.companyId, target.companyId),
|
|
),
|
|
)
|
|
.for("update");
|
|
if (!agent) throw notFound("Agent not found");
|
|
if (agentInstructionsBundleMode(agent) === "external") {
|
|
throw unprocessable(
|
|
"External host instructions do not support revision write-back. Ask an instance administrator to migrate this bundle to managed storage.",
|
|
{ code: "INSTRUCTION_MANAGED_BUNDLE_REQUIRED" },
|
|
);
|
|
}
|
|
const state = deriveBundleState(agent);
|
|
const entryFile = instructionPath(state.entryFile);
|
|
return { agent, entryFile, root: resolveManagedInstructionsRoot(agent) };
|
|
}
|
|
async function authorizeRead(
|
|
tx: Tx,
|
|
actor: AuthorizationActor,
|
|
target: InstructionTarget,
|
|
) {
|
|
return authorizeInstructionRead(tx, actor, { companyId: target.companyId, id: target.agentId });
|
|
}
|
|
// Trusted host-only reset authority. No HTTP or agent tool accepts this actor.
|
|
async function authorizePluginReset(tx: Tx, actor: PluginResetActor, target: InstructionTarget, agent: typeof agents.$inferSelect) {
|
|
const [plugin] = await tx.select().from(plugins).where(and(eq(plugins.id, actor.pluginId), eq(plugins.pluginKey, actor.pluginKey)));
|
|
const marker = agent.metadata?.paperclipManagedResource as Record<string, unknown> | undefined;
|
|
const [binding] = await tx.select().from(pluginManagedResources).where(and(
|
|
eq(pluginManagedResources.pluginId, actor.pluginId), eq(pluginManagedResources.pluginKey, actor.pluginKey),
|
|
eq(pluginManagedResources.companyId, target.companyId), eq(pluginManagedResources.resourceKind, "agent"),
|
|
eq(pluginManagedResources.resourceKey, actor.agentKey), eq(pluginManagedResources.resourceId, target.agentId),
|
|
));
|
|
if (!plugin || plugin.status !== "ready" || !plugin.manifestJson.capabilities.includes("agents.managed")
|
|
|| !plugin.manifestJson.agents?.some((declaration) => declaration.agentKey === actor.agentKey)
|
|
|| !binding || marker?.pluginId !== actor.pluginId || marker.pluginKey !== actor.pluginKey
|
|
|| marker.resourceKind !== "agent" || marker.resourceKey !== actor.agentKey) {
|
|
throw forbidden("Plugin reset is limited to its declared, bound managed agent");
|
|
}
|
|
const declared = plugin.manifestJson.agents!.find((declaration) => declaration.agentKey === actor.agentKey)!;
|
|
return { ...actor, declaredEntryFile: instructionPath(declared.instructions?.entryFile ?? "AGENTS.md") };
|
|
}
|
|
async function readForPluginReset(target: InstructionTarget, actor: PluginResetActor) {
|
|
return db.transaction(async (tx) => {
|
|
const state = await lockTarget(tx, target);
|
|
const bound = await authorizePluginReset(tx, actor, target, state.agent);
|
|
// Preserve the formerly configured entry before an explicit stock reset.
|
|
await currentFile(tx, target, state, bound);
|
|
const row = await currentFile(tx, target, { ...state, entryFile: bound.declaredEntryFile }, bound);
|
|
return { snapshot: row ? snapshot(row) : null, configuredEntryFile: state.entryFile };
|
|
});
|
|
}
|
|
async function head(tx: Tx, target: InstructionTarget, entryFile: string) {
|
|
const [result] = await tx
|
|
.select({ revision: revisions })
|
|
.from(heads)
|
|
.innerJoin(revisions, eq(revisions.id, heads.revisionId))
|
|
.where(headOwner(target, entryFile));
|
|
return result?.revision ?? null;
|
|
}
|
|
// The deployed revision tables are read-only upgrade input. Current bytes
|
|
// live in the agent directory; old UUID clients receive a content ETag.
|
|
async function currentFile(tx: Tx, target: InstructionTarget, state: Awaited<ReturnType<typeof lockTarget>>, actor?: RevisionActor) {
|
|
await adoptAgentFiles(tx, state.agent);
|
|
const bytes = await readInstructionBytes(state.root, state.entryFile);
|
|
if (bytes === null) return null;
|
|
return currentRow(target, state.entryFile, bytes, actor);
|
|
}
|
|
function currentRow(target: InstructionTarget, entryFile: string, bytes: Buffer, actor?: RevisionActor): Revision {
|
|
return { ...target, id: agentFileToken(bytes), entryFile, contentBase64: bytes.toString("base64"),
|
|
contentHash: createHash("sha256").update(bytes).digest("hex"), byteLength: bytes.length,
|
|
parentRevisionId: null, baseRevisionId: null, restoredFromRevisionId: null, source: "api",
|
|
actorAgentId: actor?.type === "agent" ? actor.agentId ?? null : null,
|
|
actorUserId: actor?.type === "board" ? actor.userId ?? null : null,
|
|
responsibleUserId: actor?.type === "board" ? actor.userId ?? null : actor?.type === "agent" ? actor.onBehalfOfUserId ?? null : null,
|
|
sourceRunId: actor && actor.type !== "plugin" ? actor.runId ?? null : null, createdAt: new Date(0) };
|
|
}
|
|
async function getRevision(
|
|
tx: Tx,
|
|
target: InstructionTarget,
|
|
entryFile: string,
|
|
id: string,
|
|
) {
|
|
const [row] = await tx
|
|
.select()
|
|
.from(revisions)
|
|
.where(and(owner(target, entryFile), eq(revisions.id, id)));
|
|
if (!row) throw notFound("Instruction revision not found");
|
|
return row;
|
|
}
|
|
async function readCurrent(
|
|
target: InstructionTarget,
|
|
actor: AuthorizationActor,
|
|
): Promise<AgentInstructionSnapshot | null> {
|
|
return db.transaction(async (tx) => {
|
|
const state = await lockTarget(tx, target, actor);
|
|
const bound = await authorizeRead(tx, actor, target);
|
|
const row = await currentFile(tx, target, state, bound);
|
|
return row ? snapshot(row) : null;
|
|
});
|
|
}
|
|
/** Trusted orchestration read for the already authorized run target. This
|
|
* never seeds or writes, and is not exposed through HTTP or model tools. */
|
|
async function readCommittedForRuntime(target: InstructionTarget): Promise<AgentInstructionSnapshot | null> {
|
|
return db.transaction(async (tx) => {
|
|
const state = await lockTarget(tx, target);
|
|
const current = await currentFile(tx, target, state);
|
|
return current ? snapshot(current) : null;
|
|
});
|
|
}
|
|
/** Rebuild disk from the current committed head under the same lock as commits. Safe after restart. */
|
|
async function materializeCurrent(target: InstructionTarget): Promise<void> {
|
|
await db.transaction(async (tx) => {
|
|
const state = await lockTarget(tx, target);
|
|
await adoptAgentFiles(tx, state.agent);
|
|
});
|
|
}
|
|
async function commitInternal(
|
|
input:
|
|
| InstructionCommitInput
|
|
| (Omit<InstructionCommitInput, "content" | "source"> & {
|
|
restoreRevisionId: string;
|
|
}),
|
|
actor: RevisionActor,
|
|
configuredEntryFile?: string,
|
|
): Promise<AgentInstructionCommitReceipt> {
|
|
instructionPath(input.entryFile);
|
|
const bytes = "content" in input ? instructionBytes(input.content) : null;
|
|
const result = await db.transaction(async (tx) => {
|
|
const state = await lockTarget(tx, input, actor.type === "plugin" ? undefined : actor);
|
|
const bound = actor.type === "plugin"
|
|
? await authorizePluginReset(tx, actor, input, state.agent)
|
|
: await authorizeInstructionCommit(tx, actor, state.agent);
|
|
if (bound.type === "plugin") {
|
|
if (input.entryFile !== bound.declaredEntryFile) throw forbidden("Plugin reset must restore its declared entry file");
|
|
if (configuredEntryFile !== state.entryFile) throw conflict("Configured instruction entry changed; read the current entry and retry", { code: "INSTRUCTION_ENTRY_CHANGED", entryFile: state.entryFile });
|
|
state.entryFile = input.entryFile;
|
|
}
|
|
if (state.entryFile !== input.entryFile)
|
|
throw conflict(
|
|
"Configured instruction entry changed; read the current entry and retry",
|
|
{ code: "INSTRUCTION_ENTRY_CHANGED", entryFile: state.entryFile },
|
|
);
|
|
await assertInstructionPathSafe(state.root, state.entryFile);
|
|
const current = await currentFile(tx, input, state, bound);
|
|
const restored =
|
|
"restoreRevisionId" in input
|
|
? await getRevision(
|
|
tx,
|
|
input,
|
|
input.entryFile,
|
|
input.restoreRevisionId,
|
|
)
|
|
: null;
|
|
const candidate = bytes ?? Buffer.from(restored!.contentBase64, "base64");
|
|
const recordSave = (row: Revision) => tx.insert(activityLog).values({ companyId: input.companyId,
|
|
actorType: bound.type === "plugin" ? "plugin" : bound.type === "board" ? "user" : "agent",
|
|
actorId: (bound.type === "plugin" ? bound.pluginId : bound.type === "board" ? bound.userId : bound.agentId)!,
|
|
agentId: bound.type === "agent" ? bound.agentId : null, runId: row.sourceRunId,
|
|
responsibleUserId: row.responsibleUserId, action: "agent.files_updated", entityType: "agent", entityId: input.agentId,
|
|
details: { path: input.entryFile, contentHash: row.contentHash, created: current === null, source: restored ? "legacy_restore" : (input as InstructionCommitInput).source } });
|
|
const bindEntry = async () => {
|
|
if (
|
|
!state.agent.adapterConfig.instructionsRootPath ||
|
|
!state.agent.adapterConfig.instructionsBundleMode ||
|
|
(bound.type === "plugin" && configuredEntryFile !== input.entryFile)
|
|
) {
|
|
await updateAgentConfigurationInTransaction(tx as unknown as Db, state.agent.id, {
|
|
adapterConfig: {
|
|
...state.agent.adapterConfig,
|
|
instructionsBundleMode: "managed",
|
|
instructionsRootPath: state.root,
|
|
instructionsEntryFile: state.entryFile,
|
|
instructionsFilePath: `${state.root}/${state.entryFile}`,
|
|
},
|
|
}, { allowPendingApprovalConfigUpdate: true });
|
|
}
|
|
};
|
|
// An exact replay can safely return the durable receipt even after its base
|
|
// advanced. Restore also deduplicates identical content, preserving history.
|
|
if (current && current.contentBase64 === candidate.toString("base64")) {
|
|
if (!await isAgentFileSaveRecorded(tx, state.agent, input.entryFile, current.contentHash)) await recordSave(current);
|
|
await bindEntry();
|
|
return { row: current, changed: false };
|
|
}
|
|
// A pre-upgrade run can still pin a historical UUID. Compare its bytes,
|
|
// never let the old head project over a newer directory.
|
|
const legacyBase = input.baseRevisionId && current?.id !== input.baseRevisionId
|
|
? await tx.select().from(revisions).where(and(owner(input, input.entryFile), eq(revisions.id, input.baseRevisionId))).then(rows => rows[0])
|
|
: null;
|
|
if ((current?.id ?? null) !== input.baseRevisionId && !(legacyBase && legacyBase.contentHash === current?.contentHash)) {
|
|
throw conflict(
|
|
"Instructions changed since the base revision. Read the current entry before saving.",
|
|
{
|
|
code: "INSTRUCTION_REVISION_CONFLICT",
|
|
baseRevisionId: input.baseRevisionId,
|
|
currentRevisionId: current?.id ?? null,
|
|
},
|
|
);
|
|
}
|
|
await invalidateAgentFileProjection(state.root);
|
|
await materializeInstructionBytes(state.root, state.entryFile, candidate);
|
|
await persistDurableAgentFiles(tx, state.agent, state.root);
|
|
const row = currentRow(input, input.entryFile, candidate, bound);
|
|
await recordSave(row);
|
|
await bindEntry();
|
|
return { row, changed: true };
|
|
});
|
|
return { ...snapshot(result.row), changed: result.changed, materialization: "current" };
|
|
}
|
|
|
|
async function history(
|
|
target: InstructionTarget & {
|
|
entryFile: string;
|
|
cursor?: string;
|
|
limit?: number;
|
|
},
|
|
actor: AuthorizationActor,
|
|
): Promise<AgentInstructionHistory> {
|
|
const limit = Math.max(1, Math.min(100, target.limit ?? 50));
|
|
return db.transaction(async (tx) => {
|
|
const state = await lockTarget(tx, target, actor);
|
|
const bound = await authorizeRead(tx, actor, target);
|
|
await currentFile(tx, target, state, bound);
|
|
instructionPath(target.entryFile);
|
|
const cursor = target.cursor
|
|
? await getRevision(tx, target, target.entryFile, target.cursor)
|
|
: null;
|
|
// Keep PostgreSQL timestamp precision; JS Date truncates microseconds and
|
|
// would skip revisions in the cursor's fractional millisecond.
|
|
const cursorTime = cursor
|
|
? sql`(select created_at from agent_instruction_revisions where id = ${cursor.id})`
|
|
: null;
|
|
const rows = await tx
|
|
.select(revisionMetadataColumns)
|
|
.from(revisions)
|
|
.where(
|
|
and(
|
|
owner(target, target.entryFile),
|
|
cursor && cursorTime
|
|
? or(
|
|
lt(revisions.createdAt, cursorTime),
|
|
and(
|
|
eq(revisions.createdAt, cursorTime),
|
|
lt(revisions.id, cursor.id),
|
|
),
|
|
)
|
|
: undefined,
|
|
),
|
|
)
|
|
.orderBy(desc(revisions.createdAt), desc(revisions.id))
|
|
.limit(limit + 1);
|
|
return {
|
|
revisions: rows.slice(0, limit).map(metadata),
|
|
nextCursor: rows.length > limit ? rows[limit - 1]!.id : null,
|
|
};
|
|
});
|
|
}
|
|
async function readRevision(
|
|
target: InstructionTarget & { entryFile: string; revisionId: string },
|
|
actor: AuthorizationActor,
|
|
) {
|
|
return db.transaction(async (tx) => {
|
|
await lockTarget(tx, target, actor);
|
|
await authorizeRead(tx, actor, target);
|
|
return snapshot(
|
|
await getRevision(
|
|
tx,
|
|
target,
|
|
instructionPath(target.entryFile),
|
|
target.revisionId,
|
|
),
|
|
);
|
|
});
|
|
}
|
|
async function diff(
|
|
target: InstructionTarget & {
|
|
entryFile: string;
|
|
fromRevisionId: string;
|
|
toRevisionId: string;
|
|
},
|
|
actor: AuthorizationActor,
|
|
): Promise<AgentInstructionDiff> {
|
|
const from = await readRevision(
|
|
{ ...target, revisionId: target.fromRevisionId },
|
|
actor,
|
|
);
|
|
const to = await readRevision(
|
|
{ ...target, revisionId: target.toRevisionId },
|
|
actor,
|
|
);
|
|
// Linear, bounded exact replacement diff. Code points keep surrogate pairs intact.
|
|
const a = Array.from(from.content),
|
|
b = Array.from(to.content);
|
|
let start = 0,
|
|
end = 0;
|
|
while (start < a.length && start < b.length && a[start] === b[start])
|
|
start++;
|
|
while (
|
|
end < a.length - start &&
|
|
end < b.length - start &&
|
|
a[a.length - end - 1] === b[b.length - end - 1]
|
|
)
|
|
end++;
|
|
return {
|
|
from,
|
|
to,
|
|
prefix: a.slice(0, start).join(""),
|
|
removed: a.slice(start, a.length - end).join(""),
|
|
added: b.slice(start, b.length - end).join(""),
|
|
suffix: end ? a.slice(-end).join("") : "",
|
|
};
|
|
}
|
|
return {
|
|
readCommittedForRuntime,
|
|
readCurrent,
|
|
readForPluginReset,
|
|
commitPluginReset: (input: Omit<InstructionCommitInput, "source"> & { configuredEntryFile: string }, actor: PluginResetActor) =>
|
|
commitInternal({ ...input, source: "api" }, actor, input.configuredEntryFile),
|
|
readRevision,
|
|
history,
|
|
diff,
|
|
materializeCurrent,
|
|
commit: (input: InstructionCommitInput, actor: AuthorizationActor) =>
|
|
commitInternal(input, actor),
|
|
restore: (
|
|
input: InstructionTarget & {
|
|
entryFile: string;
|
|
baseRevisionId: string;
|
|
revisionId: string;
|
|
},
|
|
actor: AuthorizationActor,
|
|
) =>
|
|
commitInternal({ ...input, restoreRevisionId: input.revisionId }, actor),
|
|
};
|
|
}
|