fix(routines): remap annotations and verify atomic schedule edits

This commit is contained in:
Dotta committed 2026-10-08 02:27:42 -05:00
1 parent 49fdc5899e
commit 79e5d38139
5 files changed
+81 -12

No files matched your search

@@ -147,7 +147,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| `list_projects` | `optional_agent_tool` | `discovery:projects:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `mock_extension:discovery.projects` | `scenario` + `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated project tools, persisted projects and repository workspaces, and run-bound activity.<br>catalog PRP status: `bound` |
| `list_routines` | `optional_agent_tool` | `routines:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:routines.list` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `list_secret_metadata` | `optional_agent_tool` | `secrets:metadata:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:secrets.metadata` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `manage_routine` | `optional_agent_tool` | `routines:write` | `standard`<br>`skill_test` | `admin` | `required` | no | `mock_extension:routines.manage` | `scenario`<br>`scenario_mock` | `unbound`<br>company admin/portability item event plus audit record<br>catalog PRP status: `audit_pending` |
| `manage_routine` | `optional_agent_tool` | `routines:write` | `standard`<br>`skill_test` | `admin` | `required` | no | `mock_extension:routines.manage` | `scenario` + `live`<br>`live_codex` | `routineService.create/update/createTrigger/updateTrigger`<br>company admin/portability item event plus audit record<br>catalog PRP status: `bound` |
| `read_agent_instructions` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `agentInstructionRevisionService`<br>tool-result item event plus canonical revision receipt for writes; no scenario mock coverage<br>catalog PRP status: `bound` |
| `read_document` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_document` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `read_secret_value` | `optional_agent_tool` | `secrets:values:read` | `standard`<br>`skill_test` | `secret_read` | `none` | yes | `mock_extension:secrets.value` | `scenario`<br>`scenario_mock` | `unbound`<br>redacted tool-result item event; secret value never reaches the wire<br>catalog PRP status: `audit_pending` |
@@ -469,9 +469,9 @@ Current responsibility-based paths are normative. Numbered `phase-*` or mileston
### Catalog split and deliberate replacement
- Scenario/eval catalog: **40** operations.
- Live dispatcher catalog: **45** operations.
- Shared: **28**; union/canonical authority: **57**.
- Scenario-only: `administer_company`, `export_company`, `inspect_operation_result`, `list_cases`, `list_company_skills`, `list_goals`, `list_routines`, `list_secret_metadata`, `manage_routine`, `read_secret_value`, `sync_company_skills`, `upsert_case`.
- Live dispatcher catalog: **46** operations.
- Shared: **29**; union/canonical authority: **57**.
- Scenario-only: `administer_company`, `export_company`, `inspect_operation_result`, `list_cases`, `list_company_skills`, `list_goals`, `list_routines`, `list_secret_metadata`, `read_secret_value`, `sync_company_skills`, `upsert_case`.
- Live-only: `call_api`, `create_project`, `get_agent`, `get_agent_instruction_history`, `get_approval`, `get_approval_context`, `hire_agent`, `list_project_repositories`, `read_agent_instructions`, `restore_agent_instructions`, `schedule_wake`, `search_api`, `set_task_monitor`, `set_task_title`, `submit_complaint`, `submit_suggestion`, `update_agent_instructions`.
- The generated provider contract contains exactly the live catalog; the canonical union remains the migration authority until all scenario-only operations are either implemented, deferred, or removed by an explicit reconciliation decision.
- `generic_api_request` stays exported only for controlled tests and cannot be cited as real-surface, mock-parity, or PRP product coverage.
@@ -20,8 +20,8 @@ describe("canonical semantic-catalog reconciliation authority", () => {
it("pins the reconciled op-set relationship between the two catalogs", () => {
const summary = capabilityCatalogReconciliation();
expect(summary.scenarioCount).toBe(40);
expect(summary.liveCount).toBe(45);
expect(summary.sharedCount).toBe(28);
expect(summary.liveCount).toBe(46);
expect(summary.sharedCount).toBe(29);
expect(summary.unionCount).toBe(57);
// Any operation added to or removed from either catalog without a
// reconciliation decision changes these exact sets and fails the gate.
@@ -53,7 +53,6 @@ describe("canonical semantic-catalog reconciliation authority", () => {
"list_goals",
"list_routines",
"list_secret_metadata",
"manage_routine",
"read_secret_value",
"sync_company_skills",
"upsert_case",
@@ -121,8 +120,8 @@ describe("canonical semantic-catalog reconciliation authority", () => {
it("classifies real binding status so generic_api_request is never product coverage", () => {
const summary = capabilityCatalogReconciliation();
expect(summary.byRealBindingStatus).toEqual({
live_codex: 44,
scenario_mock: 12,
live_codex: 45,
scenario_mock: 11,
test_only: 1,
});
expect(capabilityCanonicalOperation("generic_api_request")?.realBindingStatus).toBe("test_only");
@@ -113,9 +113,8 @@ describe("Capability semantic catalog and authorization", () => {
it("publishes a stable narrow catalog without credentials or control-plane-owned tools", () => {
const names = CAPABILITY_SEMANTIC_TOOL_CATALOG.map((tool) => tool.operationId);
expect(new Set(names).size).toBe(names.length);
expect(names).toHaveLength(45);
expect(names).toContain("set_task_monitor");
expect(names).toEqual(expect.arrayContaining(["submit_complaint", "submit_suggestion"]));
expect(names).toHaveLength(46);
expect(names).toEqual(expect.arrayContaining(["set_task_monitor", "submit_complaint", "submit_suggestion", "manage_routine"]));
expect(names).toContain("get_task_context");
expect(names).toContain("finish_task");
expect(names).not.toContain("checkout_task");
@@ -3,6 +3,7 @@ import { z } from "zod";
import { routines, routineTriggers, type Db } from "@paperclipai/db";
import { createRoutineSchema, updateRoutineSchema, createRoutineTriggerSchema, updateRoutineTriggerSchema } from "@paperclipai/shared";
import { routineService } from "../routines.js";
import { documentAnnotationService } from "../document-annotations.js";
import { persistActivity } from "../activity-log.js";
import { forbidden, notFound } from "../../errors.js";
@@ -74,6 +75,22 @@ export async function manageRoutine(db: Db, binding: Binding, input: z.infer<typ
if (routine && routine.latestRevisionId !== existing.latestRevisionId) {
await log("routine.revision_created", "routine", routine.id, { revisionId: routine.latestRevisionId,
revisionNumber: routine.latestRevisionNumber, changeSummary: "Updated routine", triggerCount: null });
if (input.description !== undefined) {
const doc = await service.getDescriptionDocument(routine.id);
if (doc) {
const remapped = await documentAnnotationService(db).remapOpenThreadsForRoutineDocument({
routineId: routine.id, key: doc.key, documentId: doc.id,
nextRevisionId: doc.latestRevisionId, nextRevisionNumber: doc.latestRevisionNumber, nextBody: doc.body,
});
for (const remap of remapped) {
await log("routine.document_annotation_remapped", "routine", routine.id, {
key: doc.key, documentKey: doc.key, documentId: doc.id, threadId: remap.thread.id,
revisionNumber: doc.latestRevisionNumber, anchorState: remap.thread.anchorState,
anchorConfidence: remap.thread.anchorConfidence, snapshotId: remap.snapshot.id,
});
}
}
}
}
if (input.schedule) {
const [trigger] = await db.select().from(routineTriggers).where(and(
@@ -11,6 +11,7 @@ import {
companies,
companyMemberships,
createDb,
documentAnnotationThreads,
documents,
heartbeatRuns,
issueApprovals,
@@ -24,6 +25,7 @@ import {
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { initializeRunIdentity, reserveSteeredIdentity, reconcileSteeredIdentity } from "../run-identity.js";
import { documentService } from "../documents.js";
import { documentAnnotationService } from "../document-annotations.js";
import { issueService } from "../issues.js";
import { routineService } from "../routines.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
@@ -138,6 +140,58 @@ describe("PaperclipRunnerToolAuthority", () => {
await db.update(issues).set({ responsibleUserId: null }).where(eq(issues.id, issueId));
});
it("updates descriptions and schedules atomically, remaps annotations, and rejects stale retries", async () => {
const userId = "routine-edit-owner";
await db.insert(authUsers).values({ id: userId, name: "Routine edit owner", email: "routine-edit-owner@example.test", createdAt: new Date(), updatedAt: new Date() });
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "owner" });
await db.update(issues).set({ responsibleUserId: userId }).where(eq(issues.id, issueId));
try {
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
const execute = (input: Record<string, unknown>) => authority.execute({ tool: "manage_routine", callId: String(input.idempotencyKey), arguments: input });
const created = await execute({ idempotencyKey: "routine-edit-create", action: "create", title: "Before edit", description: "Alpha selected text omega",
schedule: { cronExpression: "0 9 * * 1-5", timezone: "America/Chicago" } }) as { routineId: string; baseRevisionId: string; schedules: { triggerId: string }[] };
const service = routineService(db);
const originalDoc = (await service.getDescriptionDocument(created.routineId))!;
const thread = await documentAnnotationService(db).createRoutineThread(created.routineId, "description", {
baseRevisionId: originalDoc.latestRevisionId!, baseRevisionNumber: originalDoc.latestRevisionNumber,
selector: { quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 } },
body: "Keep this selection anchored",
}, { actorType: "user", actorId: userId, userId });
const update = { idempotencyKey: "routine-edit-update", action: "update", routineId: created.routineId, baseRevisionId: created.baseRevisionId,
title: "After edit", description: "Intro.\n\nAlpha selected text omega",
schedule: { triggerId: created.schedules[0]!.triggerId, cronExpression: "30 10 * * 1-5", timezone: "Europe/London" } };
const updated = await execute(update) as { baseRevisionId: string };
expect(updated).toMatchObject({ title: "After edit", schedules: [{ cronExpression: "30 10 * * 1-5", timezone: "Europe/London" }] });
const nextDoc = (await service.getDescriptionDocument(created.routineId))!;
expect(nextDoc.body).toBe(update.description);
const [remapped] = await db.select().from(documentAnnotationThreads).where(eq(documentAnnotationThreads.id, thread.id));
expect(remapped).toMatchObject({ currentRevisionId: nextDoc.latestRevisionId, currentRevisionNumber: nextDoc.latestRevisionNumber, anchorState: "active", selectedText: "selected text" });
expect(remapped!.normalizedStart).toBeGreaterThan(6);
const activities = () => db.select().from(activityLog).where(eq(activityLog.entityId, created.routineId));
expect(await activities()).toEqual(expect.arrayContaining([expect.objectContaining({ action: "routine.document_annotation_remapped", details: expect.objectContaining({ threadId: thread.id, documentId: nextDoc.id }) })]));
const before = await service.getDetail(created.routineId);
const beforeActivities = await activities();
expect(await execute(update)).toEqual(updated);
expect(await service.getDetail(created.routineId)).toEqual(before);
expect(await activities()).toEqual(beforeActivities);
await expect(execute({ ...update, idempotencyKey: "routine-edit-stale", title: "Stale description" })).rejects.toThrow();
await expect(execute({ ...update, idempotencyKey: "routine-edit-invalid", baseRevisionId: updated.baseRevisionId,
title: "Must roll back", description: "Changed selection", schedule: { ...update.schedule, cronExpression: "not a cron", timezone: "Europe/London" } })).rejects.toThrow();
expect(await service.getDetail(created.routineId)).toEqual(before);
expect(await service.getDescriptionDocument(created.routineId)).toEqual(nextDoc);
expect(await db.select().from(documentAnnotationThreads).where(eq(documentAnnotationThreads.id, thread.id))).toEqual([remapped]);
expect(await activities()).toEqual(beforeActivities);
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
const receipts = (run!.resultJson as { semanticToolReceipts: Record<string, unknown> }).semanticToolReceipts;
expect(receipts).not.toHaveProperty("routine-edit-stale");
expect(receipts).not.toHaveProperty("routine-edit-invalid");
expect(receipts).toHaveProperty("routine-edit-update");
} finally {
await db.update(issues).set({ responsibleUserId: null }).where(eq(issues.id, issueId));
}
});
it("advertises only real bindings and reads the bound task", async () => {
const authority = new PaperclipRunnerToolAuthority(db, {
companyId,