diff --git a/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md b/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md index e9096b92314..44cd202e5e4 100644 --- a/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md +++ b/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md @@ -147,7 +147,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o | `list_projects` | `optional_agent_tool` | `discovery:projects:read` | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | `mock_extension:discovery.projects` | `scenario` + `live`
`live_codex` | `PaperclipRunnerToolAuthority`
Authenticated project tools, persisted projects and repository workspaces, and run-bound activity.
catalog PRP status: `bound` | | `list_routines` | `optional_agent_tool` | `routines:read` | `standard`
`skill_test` | `read` | `none` | no | `mock_extension:routines.list` | `scenario`
`scenario_mock` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` | | `list_secret_metadata` | `optional_agent_tool` | `secrets:metadata:read` | `standard`
`skill_test` | `read` | `none` | no | `mock_extension:secrets.metadata` | `scenario`
`scenario_mock` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` | -| `manage_routine` | `optional_agent_tool` | `routines:write` | `standard`
`skill_test` | `admin` | `required` | no | `mock_extension:routines.manage` | `scenario`
`scenario_mock` | `unbound`
company admin/portability item event plus audit record
catalog PRP status: `audit_pending` | +| `manage_routine` | `optional_agent_tool` | `routines:write` | `standard`
`skill_test` | `admin` | `required` | no | `mock_extension:routines.manage` | `scenario` + `live`
`live_codex` | `routineService.create/update/createTrigger/updateTrigger`
company admin/portability item event plus audit record
catalog PRP status: `bound` | | `read_agent_instructions` | `always_agent_tool` | none | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | inline/no mapping | `live`
`live_codex` | `agentInstructionRevisionService`
tool-result item event plus canonical revision receipt for writes; no scenario mock coverage
catalog PRP status: `bound` | | `read_document` | `always_agent_tool` | none | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | `snapshot_read:active_task_document` | `scenario` + `live`
`live_codex` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` | | `read_secret_value` | `optional_agent_tool` | `secrets:values:read` | `standard`
`skill_test` | `secret_read` | `none` | yes | `mock_extension:secrets.value` | `scenario`
`scenario_mock` | `unbound`
redacted tool-result item event; secret value never reaches the wire
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. diff --git a/packages/paperclip-runner/src/catalog/reconciliation.test.ts b/packages/paperclip-runner/src/catalog/reconciliation.test.ts index 923fc036b33..15b38f14973 100644 --- a/packages/paperclip-runner/src/catalog/reconciliation.test.ts +++ b/packages/paperclip-runner/src/catalog/reconciliation.test.ts @@ -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"); diff --git a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts index 7dbc66243f6..bd53371761d 100644 --- a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts +++ b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts @@ -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"); diff --git a/server/src/services/native-runtime/manage-routine.ts b/server/src/services/native-runtime/manage-routine.ts index 8f384bc1bc1..7abba57ffc2 100644 --- a/server/src/services/native-runtime/manage-routine.ts +++ b/server/src/services/native-runtime/manage-routine.ts @@ -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 { 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) => 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 }).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,