Files
PaperClipAI/server/src/services/agent-instruction-revisions.ts
T
Nicky LeachandPaperclip 9f4a956b90 Add durable agent runtime synchronization hooks (#16499)
## Thinking Path

> - Paperclip manages agents and their runtime configuration.
> - Plugins can maintain external agent computers.
> - Configuration and file saves currently miss durable plugin delivery.
> - A transient activity notification cannot recover after interruption.
> - This pull request adds an opt-in agent runtime resource stream.
> - The existing dispatcher delivers and acknowledges its committed
work.
> - Plugins can prepare computers when configuration changes.

## Linked Issues or Issue Description

Related: #15631 and #16203 establish agent lifecycle and native durable
delivery.

**Problem**

Plugins cannot reliably react to agent configuration and instruction
changes without polling or waiting for execution.

**Proposed solution**

Add an opt-in `agent_runtime` stream to the existing resource journal.
Record changes at the database mutation and managed file audit
boundaries. Wake the dispatcher with content-free commit notifications.
Provide scoped runtime inputs to the selected lifecycle plugin.

**Alternatives considered**

Route-only activity events miss service and background writers.
Scheduled scans delay changes and repeatedly query unchanged state.

**Roadmap alignment**

This extends the existing plugin and agent configuration work.

## What Changed

- Add durable update/delete events for agent runtime state, including an
existing-agent baseline.
- Capture configuration changes, instruction edits, deletion, restore,
and managed run writeback.
- Add commit notifications and a reconnect scan. Register one listener
for configured, enabled runtime subscribers. Release it when none
remain.
- Keep HTTP startup independent of queued plugin callbacks.
- Add `ctx.agents.getRuntime`. Require lifecycle capability, invocation
company, enabled plugin, and owning lifecycle selection.
- Keep legacy agent/project subscriptions isolated. Manual inbox reads
retain both legacy streams.
- Resolve recovered instruction paths without reading or hashing bundle
contents. Use the company reporting graph for runtime health.
- Repair missed file events on identical save/deletion retries after a
journal failure; preserve content tokens and conditional-write checks.
- Correlate file audits with runtime revisions to deduplicate recorded
retries without a new table. Bound the lookup with the existing
company/timestamp index.
- Document the contract, migration, access boundaries, and
session-connection requirement.

## Verification

- Rebased the full stack onto origin/master after #16507.
- Final replayed stack: `pnpm -r typecheck` and `pnpm build` passed.
- All 132 affected runtime, skill, connector, publication, instruction,
managed-agent, and delivery tests passed after the latest-master rebase.
Workspace typecheck and build passed.
- Journal failure injection verifies recovery on identical file writes,
deletions, and instruction retries from an acknowledged inbox.
- Deduplication follow-up: 80 runtime/instruction/delivery tests and
server typecheck passed. Repeated retries create no audit/events; old
hashes returning after an intervening commit still repair delivery.
Built-in/native instruction writer regressions passed all 56 tests.
- PR 1 follow-up tests cover valid/cyclic reporting chains, listener
registration failure, multiple subscribers, global/company disable,
re-enable, unload, and startup with blocked callbacks.
- Database coverage includes rollback, journal failure, no-op
suppression, file saves/deletion, ordering/replay, owner/company denial,
independent-client commits, and real listener loss/reconnect.
- SDK/shared suites passed 1,059 tests during implementation.
CLI/delivery regressions also passed.
- Migration safety, map checks, diff whitespace, and local secrets/PII
scan passed.
- Hosted CI and security checks are fully green at `b296405e2`. Standard
Greptile and Apex both returned 5/5 with no unresolved findings.
- The broad local suite encountered an unchanged diagnostic test that
rejects the word "private" from an error stack. This worktree lives
under /private/tmp. The same unchanged diagnostic suite passed all 108
tests in the original checkout. Hosted full-suite checks provide
exact-head verification.

## Risks

- Apply migration 0347 before enabling a runtime subscriber. Runtime
notifications require session-level PostgreSQL LISTEN. Transaction-mode
poolers are unsupported for this opt-in feature.
- Database triggers share the mutation transaction. Journal failure
prevents configuration commits. Trigger work adds a journal insert to
relevant changes.
- Filesystem publication and SQL commit are separate boundaries. Direct
filesystem edits do not emit hooks. External clients must fence stale
revisions and retain conditional-write checks.
- The reader returns only that plugin's own instance secrets. Keep them
out of UI data, logs, plugin state, and agent files.
- No private transport integration or live remote computer qualification
is included.

## Model Used

OpenAI Codex, GPT-6, with reasoning, repository inspection, code
execution, and testing. The exact deployment model ID and context window
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
- [x] I have run tests locally and they pass (focused suites; broad
local limitation 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>
2026-10-16 07:53:04 -07:00

429 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 { and, desc, eq, getTableColumns, lt, or, sql } from "drizzle-orm";
import {
activityLog,
agents,
plugins,
pluginManagedResources,
agentInstructionHeads as heads,
agentInstructionRevisions as revisions,
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) {
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, 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 materializeInstructionBytes(state.root, state.entryFile, candidate);
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),
};
}