mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-10 20:50:08 +02:00
feat(runner): bind routine management to the live service
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
03cf6a6ecb
commit
2a5ebde7bd
8 files changed
+279
-16
No files matched your search
@@ -1 +1 @@
|
||||
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
|
||||
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
|
||||
@@ -24,7 +24,7 @@
|
||||
"prpVersion": 1,
|
||||
"nativeExecutionVersion": 1,
|
||||
"catalogVersion": 1,
|
||||
"catalogSha256": "sha256:c1d5a0b1008f9eb582aa91acf64804ef0dbb272a3117ac1a58ad22c91b7a1cb4",
|
||||
"catalogSha256": "sha256:abf2844ee3fb3e749fa60e1df0bdd8b56d196b86a1b42bb7f26e86a1716dd476",
|
||||
"driverContractVersion": 1,
|
||||
"driverKind": "paperclip-deterministic",
|
||||
"driverVersion": "1.0.0"
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
},
|
||||
{
|
||||
"path": "fixtures/evals/native-execution-seeded.json",
|
||||
"sha256": "38a46fbb0d8721db2a8c10d58344d861d4e5970c7db165bfa409f5f7b07a058b",
|
||||
"sha256": "97b0dd379097c7cf0958e4911d03ef5d95c309fcda29cb4bb570371b00773c0a",
|
||||
"expectation": "accept",
|
||||
"compatibilityCase": "canonical"
|
||||
},
|
||||
|
||||
@@ -1215,7 +1215,8 @@
|
||||
"id": "manage_routine",
|
||||
"ownership": "optional_agent_tool",
|
||||
"surfaces": [
|
||||
"scenario"
|
||||
"scenario",
|
||||
"live"
|
||||
],
|
||||
"legacyAliases": [],
|
||||
"contractCase": "protocol-action:manage_routine",
|
||||
|
||||
@@ -4,7 +4,8 @@ export const manageRoutineAction = {
|
||||
"canonical": {
|
||||
"operationId": "manage_routine",
|
||||
"surfaces": [
|
||||
"scenario"
|
||||
"scenario",
|
||||
"live"
|
||||
],
|
||||
"placement": "optional_agent_tool",
|
||||
"optionalGroup": "routines",
|
||||
@@ -18,22 +19,30 @@ export const manageRoutineAction = {
|
||||
"sideEffectClass": "admin",
|
||||
"idempotency": "required",
|
||||
"disabledByDefault": false,
|
||||
"realBindingStatus": "scenario_mock",
|
||||
"realServiceBinding": "unbound",
|
||||
"realBindingStatus": "live_codex",
|
||||
"realServiceBinding": "routineService.create/update/createTrigger/updateTrigger",
|
||||
"prpEvidence": "company admin/portability item event plus audit record",
|
||||
"prpBindingStatus": "audit_pending",
|
||||
"prpBindingStatus": "bound",
|
||||
"legacyAliases": [],
|
||||
"note": "Scenario/eval-only mock extension."
|
||||
"note": "Agent mutations are restricted to self-assigned routines."
|
||||
},
|
||||
"documentation": {
|
||||
"title": "Manage Routine",
|
||||
"description": "Manage Routine through the Capability routines capability set.",
|
||||
"note": "Scenario/eval-only mock extension."
|
||||
"description": "Create, update, pause, or resume a Paperclip routine assigned to you. Read existing routines and revisions using the authorized read API before changing them. Reuse the idempotency key on retry. Paperclip owns scheduling, budgets, concurrency and task creation.",
|
||||
"note": null
|
||||
},
|
||||
"examples": {
|
||||
"call": {
|
||||
"operationId": "manage_routine",
|
||||
"input": {}
|
||||
"input": {
|
||||
"idempotencyKey": "daily-research",
|
||||
"action": "create",
|
||||
"title": "Daily research",
|
||||
"schedule": {
|
||||
"cronExpression": "0 9 * * 1-5",
|
||||
"timezone": "America/Chicago"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scenarioCall": {
|
||||
"operationId": "manage_routine",
|
||||
@@ -54,7 +63,97 @@ export const manageRoutineAction = {
|
||||
}
|
||||
}
|
||||
},
|
||||
"live": null,
|
||||
"live": {
|
||||
"order": 60,
|
||||
"descriptor": {
|
||||
"schema": "paperclip.semantic-tool.v1",
|
||||
"operationId": "manage_routine",
|
||||
"version": 1,
|
||||
"title": "Manage routine",
|
||||
"description": "Create, update, pause, or resume a Paperclip routine assigned to you. Read existing routines and revisions using the authorized read API before changing them. Reuse the idempotency key on retry. Paperclip owns scheduling, budgets, concurrency and task creation.",
|
||||
"effect": "write",
|
||||
"exposure": "optional",
|
||||
"requiredClaims": [
|
||||
"routines:write"
|
||||
],
|
||||
"allowedModes": [
|
||||
"standard",
|
||||
"skill_test"
|
||||
],
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"idempotencyKey": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"action": {
|
||||
"enum": [
|
||||
"create",
|
||||
"update",
|
||||
"pause",
|
||||
"resume"
|
||||
]
|
||||
},
|
||||
"routineId": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"baseRevisionId": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 200000
|
||||
},
|
||||
"projectId": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"schedule": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"triggerId": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"cronExpression": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 120
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"cronExpression",
|
||||
"timezone"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"idempotencyKey",
|
||||
"action"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"outputSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"scenario": {
|
||||
"order": 29,
|
||||
"descriptor": {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
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 { persistActivity } from "../activity-log.js";
|
||||
import { forbidden, notFound } from "../../errors.js";
|
||||
|
||||
export const manageRoutineInputSchema = z.object({
|
||||
idempotencyKey: z.string().trim().min(1).max(240),
|
||||
action: z.enum(["create", "update", "pause", "resume"]),
|
||||
routineId: z.string().uuid().optional(),
|
||||
baseRevisionId: z.string().uuid().optional(),
|
||||
title: z.string().trim().min(1).max(200).optional(),
|
||||
description: z.string().max(200_000).optional(),
|
||||
projectId: z.string().uuid().optional(),
|
||||
schedule: z.object({
|
||||
triggerId: z.string().uuid().optional(),
|
||||
cronExpression: z.string().trim().min(1).max(240),
|
||||
timezone: z.string().trim().min(1).max(120),
|
||||
}).strict().optional(),
|
||||
}).strict().superRefine((input, ctx) => {
|
||||
const error = (message: string) => ctx.addIssue({ code: "custom", message });
|
||||
if (input.action === "create") {
|
||||
if (!input.title || !input.schedule || input.routineId || input.baseRevisionId || input.schedule.triggerId) error("Create requires a title and schedule, without existing routine or trigger identity");
|
||||
} else {
|
||||
if (!input.routineId || !input.baseRevisionId) error("Read the routine first; routineId and baseRevisionId are required");
|
||||
if (["pause", "resume"].includes(input.action) && [input.title, input.description, input.projectId, input.schedule].some(value => value !== undefined)) error("Pause and resume accept only the routine identity and revision");
|
||||
if (input.schedule && !input.schedule.triggerId) error("Read the existing schedule first; triggerId is required when updating it");
|
||||
}
|
||||
});
|
||||
type Binding = { companyId: string; agentId: string; issueId: string; runId: string };
|
||||
|
||||
export async function lockOwnedRoutine(db: Db, binding: Binding, id: string) {
|
||||
const [routine] = await db.select().from(routines).where(and(eq(routines.id, id), eq(routines.companyId, binding.companyId))).for("update");
|
||||
if (!routine) throw notFound("Routine not found");
|
||||
if (routine.assigneeAgentId !== binding.agentId) throw forbidden("Agents can only manage routines assigned to themselves");
|
||||
return routine;
|
||||
}
|
||||
|
||||
/** Called inside the authority's run-bound idempotency/authorization transaction. */
|
||||
export async function manageRoutine(db: Db, binding: Binding, input: z.infer<typeof manageRoutineInputSchema>) {
|
||||
const service = routineService(db);
|
||||
const actor = { agentId: binding.agentId, runId: binding.runId };
|
||||
const publications: Awaited<ReturnType<typeof persistActivity>>["publication"][] = [];
|
||||
const log = async (action: string, entityType: string, entityId: string, details: Record<string, unknown>) => {
|
||||
const { publication } = await persistActivity(db, { companyId: binding.companyId, actorType: "agent", actorId: binding.agentId,
|
||||
agentId: binding.agentId, runId: binding.runId, issueId: binding.issueId, action, entityType, entityId,
|
||||
details: { ...details, source: "manage_routine" } });
|
||||
publications.push(publication);
|
||||
};
|
||||
const logRevision = async (routineId: string, revision: { id: string; revisionNumber: number; changeSummary: string | null; snapshot: { triggers: unknown[] } }) =>
|
||||
log("routine.revision_created", "routine", routineId, { revisionId: revision.id, revisionNumber: revision.revisionNumber,
|
||||
changeSummary: revision.changeSummary, triggerCount: revision.snapshot.triggers.length });
|
||||
let routine;
|
||||
if (input.action === "create") {
|
||||
routine = await service.create(binding.companyId, createRoutineSchema.parse({
|
||||
title: input.title, description: input.description, projectId: input.projectId,
|
||||
assigneeAgentId: binding.agentId, parentIssueId: binding.issueId,
|
||||
}), actor);
|
||||
await log("routine.revision_created", "routine", routine.id, { revisionId: routine.latestRevisionId,
|
||||
revisionNumber: routine.latestRevisionNumber, changeSummary: "Created routine", triggerCount: 0 });
|
||||
const created = await service.createTrigger(routine.id, createRoutineTriggerSchema.parse({ kind: "schedule", ...input.schedule }), actor);
|
||||
await log("routine.trigger_created", "routine_trigger", created.trigger.id, { routineId: routine.id, kind: "schedule" });
|
||||
await logRevision(routine.id, created.revision);
|
||||
} else {
|
||||
const existing = await lockOwnedRoutine(db, binding, input.routineId!);
|
||||
routine = await service.update(existing.id, updateRoutineSchema.parse({
|
||||
baseRevisionId: input.baseRevisionId,
|
||||
...(input.action === "pause" ? { status: "paused" } : input.action === "resume" ? { status: "active" } : {
|
||||
title: input.title, description: input.description, projectId: input.projectId,
|
||||
}),
|
||||
}), actor);
|
||||
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.schedule) {
|
||||
const [trigger] = await db.select().from(routineTriggers).where(and(
|
||||
eq(routineTriggers.id, input.schedule.triggerId!), eq(routineTriggers.routineId, existing.id), eq(routineTriggers.companyId, binding.companyId),
|
||||
)).for("update");
|
||||
if (!trigger || trigger.kind !== "schedule" || trigger.archived) throw notFound("Routine schedule not found");
|
||||
const updated = await service.updateTrigger(trigger.id, updateRoutineTriggerSchema.parse({ cronExpression: input.schedule.cronExpression, timezone: input.schedule.timezone }), actor);
|
||||
if (!updated) throw notFound("Routine schedule not found");
|
||||
await log("routine.trigger_updated", "routine", existing.id, { triggerId: trigger.id, kind: "schedule" });
|
||||
await logRevision(existing.id, updated.revision);
|
||||
}
|
||||
}
|
||||
if (!routine) throw notFound("Routine not found");
|
||||
const current = await service.getDetail(routine.id);
|
||||
if (!current) throw notFound("Routine not found");
|
||||
await log(input.action === "create" ? "routine.created" : "routine.updated", "routine", current.id,
|
||||
{ title: current.title, operation: input.action, revisionId: current.latestRevisionId });
|
||||
return { publications, result: {
|
||||
routineId: current.id, title: current.title, status: current.status,
|
||||
baseRevisionId: current.latestRevisionId, assigneeAgentId: current.assigneeAgentId,
|
||||
schedules: current.triggers.filter(trigger => trigger.kind === "schedule").map(trigger => ({
|
||||
triggerId: trigger.id, cronExpression: trigger.cronExpression, timezone: trigger.timezone, enabled: trigger.enabled, nextRunAt: trigger.nextRunAt,
|
||||
})),
|
||||
} };
|
||||
}
|
||||
@@ -16,11 +16,15 @@ import {
|
||||
issueComments,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
routines,
|
||||
routineRuns,
|
||||
routineTriggers,
|
||||
} from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
||||
import { initializeRunIdentity, reserveSteeredIdentity, reconcileSteeredIdentity } from "../run-identity.js";
|
||||
import { documentService } from "../documents.js";
|
||||
import { issueService } from "../issues.js";
|
||||
import { routineService } from "../routines.js";
|
||||
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
|
||||
import { createAssignedMcpTools } from "./assigned-mcp-tools.js";
|
||||
import type { ToolGatewayService } from "../tool-gateway.js";
|
||||
@@ -95,6 +99,44 @@ describe("PaperclipRunnerToolAuthority", () => {
|
||||
await temporary?.cleanup();
|
||||
});
|
||||
|
||||
it("manages self-assigned routines with durable retry and revision protection", async () => {
|
||||
const userId = "routine-owner";
|
||||
await db.insert(authUsers).values({ id: userId, name: "Routine owner", email: "routine-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));
|
||||
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
|
||||
const input = { idempotencyKey: "routine-create", action: "create", title: "Daily research",
|
||||
schedule: { cronExpression: "0 9 * * 1-5", timezone: "America/Chicago" } };
|
||||
const created = await authority.execute({ tool: "manage_routine", callId: "routine-create", arguments: input }) as { routineId: string; baseRevisionId: string };
|
||||
expect(created).toMatchObject({ status: "active", assigneeAgentId: agentId, schedules: [{ timezone: "America/Chicago" }] });
|
||||
expect(await authority.execute({ tool: "manage_routine", callId: "retry", arguments: input })).toEqual(created);
|
||||
expect(await db.select().from(routineTriggers).where(eq(routineTriggers.routineId, created.routineId))).toHaveLength(1);
|
||||
const paused = await authority.execute({ tool: "manage_routine", callId: "pause", arguments: {
|
||||
idempotencyKey: "routine-pause", action: "pause", routineId: created.routineId, baseRevisionId: created.baseRevisionId,
|
||||
} }) as { baseRevisionId: string };
|
||||
expect(paused).toMatchObject({ status: "paused" });
|
||||
const scheduler = routineService(db, { runtimeEnv: {}, heartbeat: { wakeup: async () => null } });
|
||||
const due = new Date("2026-10-07T14:00:00Z");
|
||||
await db.update(routineTriggers).set({ nextRunAt: due }).where(eq(routineTriggers.routineId, created.routineId));
|
||||
expect(await scheduler.tickScheduledTriggers(due)).toEqual({ triggered: 0 });
|
||||
await expect(authority.execute({ tool: "manage_routine", callId: "stale", arguments: {
|
||||
idempotencyKey: "routine-stale", action: "resume", routineId: created.routineId, baseRevisionId: created.baseRevisionId,
|
||||
} })).rejects.toThrow();
|
||||
expect(await authority.execute({ tool: "manage_routine", callId: "resume", arguments: {
|
||||
idempotencyKey: "routine-resume", action: "resume", routineId: created.routineId, baseRevisionId: paused.baseRevisionId,
|
||||
} })).toMatchObject({ status: "active" });
|
||||
await db.update(routineTriggers).set({ nextRunAt: due }).where(eq(routineTriggers.routineId, created.routineId));
|
||||
expect(await scheduler.tickScheduledTriggers(due)).toEqual({ triggered: 1 });
|
||||
expect(await scheduler.tickScheduledTriggers(due)).toEqual({ triggered: 0 });
|
||||
const firings = await db.select().from(routineRuns).where(eq(routineRuns.routineId, created.routineId));
|
||||
expect(firings).toMatchObject([{ status: "issue_created", source: "schedule" }]);
|
||||
const [scheduledIssue] = await db.select().from(issues).where(eq(issues.id, firings[0]!.linkedIssueId!));
|
||||
expect(scheduledIssue).toMatchObject({ companyId, assigneeAgentId: agentId, responsibleUserId: userId });
|
||||
await db.update(routines).set({ assigneeAgentId: null }).where(eq(routines.id, created.routineId));
|
||||
await expect(authority.execute({ tool: "manage_routine", callId: "reassigned-retry", arguments: input })).rejects.toThrow("assigned to themselves");
|
||||
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,
|
||||
@@ -102,7 +144,7 @@ describe("PaperclipRunnerToolAuthority", () => {
|
||||
issueId,
|
||||
runId,
|
||||
});
|
||||
expect(authority.definitions()).toHaveLength(38);
|
||||
expect(authority.definitions()).toHaveLength(39);
|
||||
const questions = authority.definitions().find(tool => tool.name === "request_human_input")!;
|
||||
expect(questions.description).toContain("ask only the next unanswered question");
|
||||
expect(questions.description).toContain("Never fabricate answers");
|
||||
@@ -127,6 +169,7 @@ describe("PaperclipRunnerToolAuthority", () => {
|
||||
"submit_suggestion",
|
||||
"request_human_input",
|
||||
"create_task",
|
||||
"manage_routine",
|
||||
"set_dependencies",
|
||||
"list_documents",
|
||||
"read_document",
|
||||
@@ -801,6 +844,7 @@ describe("PaperclipRunnerToolAuthority", () => {
|
||||
});
|
||||
|
||||
it("writes a real revisioned document and replays the mutation receipt", async () => {
|
||||
const before = await db.select().from(documents).where(eq(documents.companyId, companyId));
|
||||
const body = credentialDocumentBody;
|
||||
const authority = new PaperclipRunnerToolAuthority(db, {
|
||||
companyId,
|
||||
@@ -839,7 +883,7 @@ describe("PaperclipRunnerToolAuthority", () => {
|
||||
.select()
|
||||
.from(documents)
|
||||
.where(eq(documents.companyId, companyId)),
|
||||
).toHaveLength(1);
|
||||
).toHaveLength(before.length + 1);
|
||||
expect(await documentService(db).getIssueDocumentByKey(issueId, "plan"))
|
||||
.toMatchObject({ body: credentialDocumentBody });
|
||||
const documentActivity = await db
|
||||
@@ -969,6 +1013,7 @@ describe("PaperclipRunnerToolAuthority", () => {
|
||||
});
|
||||
|
||||
it("creates ordinary children, preserves blockers, and deduplicates across runs", async () => {
|
||||
const initialChildCount = (await db.select().from(issues).where(eq(issues.parentId, issueId))).length;
|
||||
const wakes: Array<{ agentId: string; options: Record<string, unknown> }> =
|
||||
[];
|
||||
const authority = new PaperclipRunnerToolAuthority(db, {
|
||||
@@ -1185,7 +1230,7 @@ describe("PaperclipRunnerToolAuthority", () => {
|
||||
expect(retryWakes).toHaveLength(0);
|
||||
expect(
|
||||
await db.select().from(issues).where(eq(issues.parentId, issueId)),
|
||||
).toHaveLength(3);
|
||||
).toHaveLength(initialChildCount + 3);
|
||||
});
|
||||
|
||||
it("rejects mutations after reassignment, run replacement, or terminalization", async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readTaskQuestionContext } from "../issue-question-context.js";
|
||||
import { isConversation } from "../agent-conversations.js";
|
||||
import { setIssueTitle } from "../issue-title.js";
|
||||
import { manageRoutine, manageRoutineInputSchema, lockOwnedRoutine } from "./manage-routine.js";
|
||||
import { externalObjectService } from "../external-objects.js";
|
||||
import { instanceSettingsService } from "../instance-settings.js";
|
||||
import { setIssueTitleSchema } from "@paperclipai/shared";
|
||||
@@ -99,6 +100,7 @@ import {
|
||||
|
||||
const IMPLEMENTED_OPERATIONS = new Set([
|
||||
"submit_complaint", "submit_suggestion",
|
||||
"manage_routine",
|
||||
"read_agent_instructions", "update_agent_instructions", "get_agent_instruction_history", "restore_agent_instructions",
|
||||
"search_api", "call_api", "hire_agent",
|
||||
"get_task_context", "get_task_history", "search_tasks", "report_progress", "set_task_title",
|
||||
@@ -413,6 +415,21 @@ export class PaperclipRunnerToolAuthority {
|
||||
...parsed.data, kind: call.tool === "submit_complaint" ? "complaint" : "suggestion",
|
||||
}, (tx) => this.#lockAuthorizedMutationContext(tx));
|
||||
}
|
||||
case "manage_routine": {
|
||||
const parsed = manageRoutineInputSchema.parse(input);
|
||||
let publications: Awaited<ReturnType<typeof persistActivity>>["publication"][] = [];
|
||||
const result = await this.#withMutationReceipt("manage_routine", parsed.idempotencyKey, input, async tx => {
|
||||
const changed = await manageRoutine(tx, this.binding, parsed);
|
||||
publications = changed.publications;
|
||||
return changed.result;
|
||||
}, { beforeReceiptReplay: async (tx, context) => {
|
||||
const prior = record(record(record(context.run.resultJson).semanticToolReceipts)[parsed.idempotencyKey]);
|
||||
const routineId = parsed.routineId ?? requiredString(record(prior.result).routineId);
|
||||
await lockOwnedRoutine(tx, this.binding, routineId);
|
||||
} });
|
||||
for (const publication of publications) publishActivity(publication);
|
||||
return result;
|
||||
}
|
||||
case "read_agent_instructions":
|
||||
case "get_agent_instruction_history":
|
||||
return executeAgentInstructionTool({ db: this.db, binding: {
|
||||
|
||||
Reference in new issue
Block a user