mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
fix(routines): remap annotations and verify atomic schedule edits
This commit is contained in:
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,
|
||||
|
||||
Reference in new issue
Block a user