@danypops/papyrus 0.46.1 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,17 +19,25 @@
19
19
  *
20
20
  * remove/remove_subtree/restore are not duplicated here -- see ./artifact-trash-vehicle.ts.
21
21
  */
22
- import { VehicleError, type VehicleLimits } from "@danypops/vehicle-core";
22
+ import { VehicleError, type VehicleLimits, type VehicleOperationContext } from "@danypops/vehicle-core";
23
23
  import type { VehicleRegistry } from "@danypops/vehicle-server";
24
24
  import type { ArtifactStore } from "../artifact/artifact-store.ts";
25
- import { GATE_TIMEOUT_MAX_MS, TASK_CREATE_IDEMPOTENCY_KEY_MAX_LENGTH } from "../constants.ts";
25
+ import { GATE_TIMEOUT_MAX_MS, TASK_CREATE_IDEMPOTENCY_KEY_MAX_LENGTH, TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH } from "../constants.ts";
26
26
  import { PROOF_TYPES } from "../domain/checklist.ts";
27
27
  import { GATE_TYPES } from "../domain/gate.ts";
28
28
  import type { TaskViewMode } from "../domain/task-scope.ts";
29
29
  import { tasksOperations } from "../modules/tasks.ts";
30
30
  import type { SessionIdentity } from "../session-identity/session-identity-service.ts";
31
+ import { TaskMutationIdempotencyConflictError, TaskMutationPendingError } from "../stores/task-mutation-request-store.ts";
31
32
  import type { TaskExecutionPlan } from "../task/task-execution.ts";
32
- import { type TaskCompletion, TaskProjectAmbiguousError, TaskProjectNotFoundError, type Tasks } from "../task/task-service.ts";
33
+ import {
34
+ type TaskCompletion,
35
+ TaskInvalidTransitionError,
36
+ TaskMutationReceiptNotFoundError,
37
+ TaskProjectAmbiguousError,
38
+ TaskProjectNotFoundError,
39
+ type Tasks,
40
+ } from "../task/task-service.ts";
33
41
  import {
34
42
  booleanProp,
35
43
  classifySessionAuthorization,
@@ -75,12 +83,20 @@ const GATE_OPERATION_LIMITS: VehicleLimits = {
75
83
  const objectProp = { type: "object" } as const;
76
84
  const arrayProp = { type: "array" } as const;
77
85
  const _boolProp = { type: "boolean" } as const;
86
+ const mutationIdempotencyProp = {
87
+ type: "string",
88
+ minLength: 1,
89
+ maxLength: TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH,
90
+ description:
91
+ "Retry key for this exact mutation. Reuse the same key after an unknown outcome; inspect mutation_status before choosing a new action.",
92
+ } as const;
78
93
 
79
94
  const gateProp = {
80
95
  type: "array",
81
96
  description: "Validation gates run by tasks.run_gates and tasks.complete.",
82
97
  items: {
83
98
  type: "object",
99
+ description: "Accepted gate shape: {type, target, expect?, timeoutMs?}.",
84
100
  properties: {
85
101
  type: { type: "string", enum: GATE_TYPES, description: "Gate evaluator." },
86
102
  target: { type: "string", minLength: 1, description: "Path, command, text target, or test command." },
@@ -109,6 +125,7 @@ const checklistProp = {
109
125
  minItems: 1,
110
126
  items: {
111
127
  type: "object",
128
+ description: "Accepted proof shape: {type, target, expect?}.",
112
129
  properties: {
113
130
  type: { type: "string", enum: PROOF_TYPES },
114
131
  target: { type: "string", minLength: 1 },
@@ -249,13 +266,58 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
249
266
  * vehicle-registry's own secure-by-default handler-failed opacity still applies to a genuine
250
267
  * unexpected crash (see artifact-vehicle-shared.ts's classify* helpers).
251
268
  */
269
+ const throwLifecycleError = (error: unknown): never => {
270
+ if (error instanceof TaskInvalidTransitionError) {
271
+ throw new VehicleError("invalid-transition", error.message, {
272
+ category: "conflict",
273
+ details: {
274
+ operation: error.operation,
275
+ currentStatus: error.currentStatus,
276
+ intendedStatus: error.intendedStatus,
277
+ allowedActions: [...error.allowedActions],
278
+ recovery: error.recovery,
279
+ },
280
+ });
281
+ }
282
+ if (error instanceof TaskMutationIdempotencyConflictError) {
283
+ throw new VehicleError("idempotency-key-conflict", error.message, { category: "conflict" });
284
+ }
285
+ if (error instanceof TaskMutationPendingError) {
286
+ throw new VehicleError("mutation-pending", error.message, {
287
+ category: "conflict",
288
+ details: { receiptId: error.receiptId, operation: error.operation },
289
+ });
290
+ }
291
+ if (error instanceof TaskMutationReceiptNotFoundError) {
292
+ throw new VehicleError("mutation-receipt-not-found", error.message, { category: "not_found" });
293
+ }
294
+ throw error;
295
+ };
296
+ const classifyLifecycle = <T>(run: () => T): T => {
297
+ try {
298
+ const result = run();
299
+ return result instanceof Promise ? (result.catch(throwLifecycleError) as T) : result;
300
+ } catch (error) {
301
+ return throwLifecycleError(error);
302
+ }
303
+ };
252
304
  const call = (name: string, input: Record<string, unknown>): unknown =>
253
305
  classifySessionAuthorization(() =>
254
306
  classifyTaskCreateIdempotency(() =>
255
- classifyTaskExecutionBounds(() => classifyTaskDependencyCycles(() => moduleOperations.get(name)!.execute(input))),
307
+ classifyTaskExecutionBounds(() =>
308
+ classifyTaskDependencyCycles(() => classifyLifecycle(() => moduleOperations.get(name)!.execute(input))),
309
+ ),
256
310
  ),
257
311
  );
258
312
  const define = createOperationDefiner(registry, OWNER, "tasks", ["tasks:read", "tasks:write"], call);
313
+ const mutationInput = (
314
+ input: Record<string, unknown>,
315
+ context: VehicleOperationContext<Record<string, unknown>>,
316
+ ): Record<string, unknown> => ({
317
+ ...input,
318
+ idempotency_key: input.idempotency_key ?? context.idempotencyKey,
319
+ idempotency_caller: context.principal?.id ?? "anonymous",
320
+ });
259
321
 
260
322
  const resolveProject = (reference: string) => {
261
323
  try {
@@ -286,7 +348,7 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
286
348
 
287
349
  define(
288
350
  "create",
289
- "Creates a Task -- work: desired outcomes, gates, checklists, and dependencies. project_root is required (no ambient cwd server-side). Prefer parent_name/depends_on_names over parent_id/depends_on -- resolved server-side.",
351
+ 'Creates a Task -- work: desired outcomes, gates, checklists, and dependencies. Gates are {type, target, expect?, timeoutMs?}; for example [{type: "command", target: "bun run typecheck", timeoutMs: 60000}]. Checklist criteria are {proof: [{type, target, expect?}]}. project_root is required (no ambient cwd server-side). Prefer parent_name/depends_on_names over parent_id/depends_on -- resolved server-side.',
290
352
  "local-write",
291
353
  {
292
354
  title: stringProp,
@@ -520,7 +582,8 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
520
582
  ): void => {
521
583
  define(action, description, "local-write", properties, required, resolve, (resolvedInput, context) => {
522
584
  const claims = context.principal?.claims as { sessionId?: string; sessionSecret?: string } | undefined;
523
- return call(`tasks.${action}`, { ...resolvedInput, session_id: claims?.sessionId, session_secret: claims?.sessionSecret });
585
+ const operationInput = action === "pause" || action === "unpause" ? mutationInput(resolvedInput, context) : resolvedInput;
586
+ return call(`tasks.${action}`, { ...operationInput, session_id: claims?.sessionId, session_secret: claims?.sessionSecret });
524
587
  });
525
588
  };
526
589
 
@@ -534,63 +597,67 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
534
597
  id: resolveTaskId(artifacts, tasks, { projectRoot: input.project_root as string | undefined }, input.id, input.name),
535
598
  }),
536
599
  );
537
- focusOperation("pause", "Pauses the active Task Focus without clearing it.", { reason: stringProp }, [], (input) => input);
538
- focusOperation("unpause", "Resumes a paused Task Focus.", {}, [], (input) => input);
539
- focusOperation("clear_focus", "Clears the active Task Focus.", {}, [], (input) => input);
540
-
541
- define(
542
- "start",
543
- "Lifecycle transition: todo -> in-progress.",
544
- "local-write",
545
- { id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp },
546
- [],
547
- resolveIdAndScope,
548
- );
549
- define(
550
- "submit",
551
- "Lifecycle transition: in-progress -> review.",
552
- "local-write",
553
- { id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp },
554
- [],
555
- resolveIdAndScope,
556
- );
557
- define(
558
- "reject",
559
- "Lifecycle transition: review -> rejected.",
560
- "local-write",
561
- { id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp },
562
- [],
563
- resolveIdAndScope,
564
- );
565
- define(
566
- "retry",
567
- "Lifecycle transition: rejected -> in-progress.",
568
- "local-write",
569
- { id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp },
600
+ focusOperation(
601
+ "pause",
602
+ "Pauses Task Focus. Destination-state idempotent: replaying after success returns changed=false. Reuse idempotency_key after an unknown outcome.",
603
+ { reason: stringProp, idempotency_key: mutationIdempotencyProp },
570
604
  [],
571
- resolveIdAndScope,
605
+ (input) => input,
572
606
  );
573
- define(
574
- "cancel",
575
- "Lifecycle transition to canceled (terminal) from todo/in-progress/review/rejected. Reversible via tasks.reopen if this turns out to be premature.",
576
- "local-write",
577
- { id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp },
607
+ focusOperation(
608
+ "unpause",
609
+ "Resumes paused Task Focus. Destination-state idempotent: replaying after success returns changed=false. Reuse idempotency_key after an unknown outcome.",
610
+ { idempotency_key: mutationIdempotencyProp },
578
611
  [],
579
- resolveIdAndScope,
612
+ (input) => input,
580
613
  );
614
+ focusOperation("clear_focus", "Clears the active Task Focus.", {}, [], (input) => input);
581
615
 
582
- define(
583
- "reopen",
584
- "Lifecycle transition: canceled -> todo. For a task legitimately canceled through a normal transition (e.g. a deliberate pause/park) that should resume -- distinct from tasks.update's status:todo path, which only recovers a task that was terminal at its own creation (a caller mistake), never one canceled/rejected later.",
585
- "local-write",
586
- { id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp },
587
- [],
588
- resolveIdAndScope,
616
+ const transitionOperation = (action: "start" | "submit" | "reject" | "retry" | "cancel" | "reopen", description: string): void =>
617
+ define(
618
+ action,
619
+ `${description} Destination-state idempotent: a replay after success returns changed=false. After an unknown outcome, call tasks.show and mutation_status, then reuse the SAME idempotency_key; never retry with a new key from stale state.`,
620
+ "local-write",
621
+ {
622
+ id: stringProp,
623
+ name: stringProp,
624
+ reason: stringProp,
625
+ session_id: stringProp,
626
+ project_root: stringProp,
627
+ idempotency_key: mutationIdempotencyProp,
628
+ },
629
+ [],
630
+ resolveIdAndScope,
631
+ (input, context) => {
632
+ const result = call(`tasks.${action}`, mutationInput(input, context)) as {
633
+ title: string;
634
+ status: string;
635
+ changed: boolean;
636
+ receiptId?: string;
637
+ replayed?: boolean;
638
+ };
639
+ const text = result.changed
640
+ ? `${result.title} transitioned to ${result.status}.`
641
+ : result.replayed
642
+ ? `Recovered the prior ${action} receipt for ${result.title}; call tasks.show to confirm its current status before the next action.`
643
+ : `${result.title} was already ${result.status}; replay was a safe no-op.`;
644
+ return { ...result, content: [{ type: "text" as const, text }] };
645
+ },
646
+ );
647
+
648
+ transitionOperation("start", "Lifecycle transition: todo -> in-progress.");
649
+ transitionOperation("submit", "Lifecycle transition: in-progress -> review.");
650
+ transitionOperation("reject", "Lifecycle transition: review -> rejected.");
651
+ transitionOperation("retry", "Lifecycle transition: rejected -> in-progress.");
652
+ transitionOperation(
653
+ "cancel",
654
+ "Lifecycle transition to canceled (terminal) from todo/in-progress/review/rejected. Reversible via tasks.reopen if premature.",
589
655
  );
656
+ transitionOperation("reopen", "Lifecycle transition: canceled -> todo for work that should resume.");
590
657
 
591
658
  define(
592
659
  "complete",
593
- "Runs gates + checklist-proof review, then focuses one deterministic ready successor without claiming effort. Rejects (not completes) on gate/checklist failure.",
660
+ "Runs gates + checklist-proof review, then focuses one deterministic ready successor without claiming effort. Rejects on gate/checklist failure. Reuse the same idempotency_key after an unknown outcome so gates and history are not run twice; inspect mutation_status before choosing a new action. A replay after done is a changed=false no-op.",
594
661
  "local-write",
595
662
  {
596
663
  id: stringProp,
@@ -601,11 +668,12 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
601
668
  scope: { type: "string", enum: ["project", "graph", "all"] },
602
669
  root_task_id: stringProp,
603
670
  root_task_name: stringProp,
671
+ idempotency_key: mutationIdempotencyProp,
604
672
  },
605
673
  [],
606
674
  resolveIdAndScope,
607
- async (input) => {
608
- const result = (await call("tasks.complete", input)) as TaskCompletion;
675
+ async (input, context) => {
676
+ const result = (await call("tasks.complete", mutationInput(input, context))) as TaskCompletion;
609
677
  const dependencyIds = result.blocked.flatMap((entry) => entry.dependencyIds);
610
678
  const labels = labelsById(artifacts, dependencyIds);
611
679
  return { ...result, content: [{ type: "text" as const, text: completionContentText(labels, result) }] };
@@ -613,6 +681,16 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
613
681
  GATE_OPERATION_LIMITS,
614
682
  );
615
683
 
684
+ define(
685
+ "mutation_status",
686
+ "Resolves an unknown lifecycle mutation outcome by the original idempotency_key. Read this receipt before selecting another transition; never invent a replacement key for the same attempt.",
687
+ "read",
688
+ { idempotency_key: mutationIdempotencyProp },
689
+ ["idempotency_key"],
690
+ (input) => input,
691
+ (input, context) => call("tasks.mutation_status", mutationInput(input, context)),
692
+ );
693
+
616
694
  define(
617
695
  "run_gates",
618
696
  "Runs a Task's configured gates without transitioning its status -- for checking readiness before submit/complete.",
@@ -54,6 +54,7 @@ const FK_COLUMNS: ReadonlyArray<{ table: string; column: string }> = [
54
54
  { table: "task_events", column: "task_id" },
55
55
  { table: "task_scopes", column: "task_id" },
56
56
  { table: "task_views", column: "root_task_id" },
57
+ { table: "task_mutation_requests", column: "task_id" },
57
58
  { table: "artifact_events", column: "artifact_id" },
58
59
  { table: "artifact_events", column: "related_id" },
59
60
  ];
@@ -68,6 +69,7 @@ const TEXT_SCAN_COLUMNS: ReadonlyArray<{ table: string; column: string }> = [
68
69
  { table: "artifacts", column: "extra" },
69
70
  { table: "task_events", column: "reason" },
70
71
  { table: "task_events", column: "evidence_json" },
72
+ { table: "task_mutation_requests", column: "response_json" },
71
73
  ];
72
74
 
73
75
  /** Audit tables whose append-only guard must be suspended for exactly this migration's duration. */
package/src/index.ts CHANGED
@@ -35,4 +35,12 @@ export { taskContext } from "./task/task-context.ts";
35
35
  export { projectTaskExecution, type TaskExecutionPlan, type TaskExecutionState } from "./task/task-execution.ts";
36
36
  export { projectTaskGraph, type TaskGraphView } from "./task/task-graph-view.ts";
37
37
  export { fallbackLabel, projectTaskRelationships } from "./task/task-relationship-view.ts";
38
- export type { TaskCompletion, TaskGraph, TaskNode, TaskStatus } from "./task/task-service.ts";
38
+ export type {
39
+ TaskCompletion,
40
+ TaskGraph,
41
+ TaskLifecycleMutationResult,
42
+ TaskMutationReceiptView,
43
+ TaskNode,
44
+ TaskStatus,
45
+ } from "./task/task-service.ts";
46
+ export { TaskInvalidTransitionError, TaskMutationReceiptNotFoundError } from "./task/task-service.ts";
@@ -28,7 +28,7 @@ import type { OperationDefinition } from "../module-registry.ts";
28
28
  import type { SessionIdentity } from "../session-identity/session-identity-service.ts";
29
29
  import { taskContext } from "../task/task-context.ts";
30
30
  import { projectTaskExecution } from "../task/task-execution.ts";
31
- import type { TaskStatus, Tasks } from "../task/task-service.ts";
31
+ import type { TaskMutationRequestContext, TaskStatus, Tasks } from "../task/task-service.ts";
32
32
  import { type OperationInput, optionalBoolean, optionalNumber, optionalString, optionalStringArray, string } from "./operation-input.ts";
33
33
 
34
34
  const MODULE_ID = "tasks";
@@ -40,6 +40,11 @@ const eventContext = (input: OperationInput): TaskEventContext => ({
40
40
  reason: optionalString(input, "reason"),
41
41
  });
42
42
 
43
+ const mutationRequest = (input: OperationInput): TaskMutationRequestContext => ({
44
+ key: optionalString(input, "idempotency_key"),
45
+ caller: optionalString(input, "idempotency_caller"),
46
+ });
47
+
43
48
  const taskFilter = (input: OperationInput) => ({
44
49
  status: optionalString(input, "status"),
45
50
  text: optionalString(input, "text"),
@@ -83,6 +88,7 @@ export const TASKS_OPERATION_NAMES = [
83
88
  "tasks.start",
84
89
  "tasks.submit",
85
90
  "tasks.complete",
91
+ "tasks.mutation_status",
86
92
  "tasks.run_gates",
87
93
  "tasks.set_checklist",
88
94
  "tasks.set_gates",
@@ -195,20 +201,29 @@ export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore, sessionI
195
201
  }),
196
202
  define("tasks.pause", (input: OperationInput) => {
197
203
  guardFocusMutation(input);
198
- return tasks.pauseFocus(eventContext(input));
204
+ return tasks.pauseFocus(eventContext(input), mutationRequest(input));
199
205
  }),
200
206
  define("tasks.unpause", (input: OperationInput) => {
201
207
  guardFocusMutation(input);
202
- return tasks.unpauseFocus(eventContext(input));
208
+ return tasks.unpauseFocus(eventContext(input), mutationRequest(input));
203
209
  }),
204
210
  define("tasks.clear_focus", (input: OperationInput) => {
205
211
  guardFocusMutation(input);
206
212
  return tasks.clearFocus(eventContext(input));
207
213
  }),
208
214
  define("tasks.reap_stale_focus", () => ({ removed: tasks.reapStaleFocus() })),
209
- define("tasks.start", (input: OperationInput) => tasks.transition(string(input, "id"), "start", eventContext(input))),
210
- define("tasks.submit", (input: OperationInput) => tasks.transition(string(input, "id"), "submit", eventContext(input))),
211
- define("tasks.complete", (input: OperationInput) => tasks.completeAsync(string(input, "id"), eventContext(input))),
215
+ define("tasks.start", (input: OperationInput) =>
216
+ tasks.transition(string(input, "id"), "start", eventContext(input), mutationRequest(input)),
217
+ ),
218
+ define("tasks.submit", (input: OperationInput) =>
219
+ tasks.transition(string(input, "id"), "submit", eventContext(input), mutationRequest(input)),
220
+ ),
221
+ define("tasks.complete", (input: OperationInput) =>
222
+ tasks.completeAsync(string(input, "id"), eventContext(input), {}, mutationRequest(input)),
223
+ ),
224
+ define("tasks.mutation_status", (input: OperationInput) =>
225
+ tasks.mutationStatus(string(input, "idempotency_key"), optionalString(input, "idempotency_caller")),
226
+ ),
212
227
  define("tasks.run_gates", (input: OperationInput) => tasks.runGates(string(input, "id"), eventContext(input))),
213
228
  define("tasks.set_checklist", (input: OperationInput) => tasks.setChecklist(string(input, "id"), input.checklist as Checklist)),
214
229
  define("tasks.set_gates", (input: OperationInput) =>
@@ -222,10 +237,18 @@ export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore, sessionI
222
237
  optionalString(input, "verbosity") === "summary" ? "summary" : "full",
223
238
  ),
224
239
  ),
225
- define("tasks.reject", (input: OperationInput) => tasks.transition(string(input, "id"), "reject", eventContext(input))),
226
- define("tasks.retry", (input: OperationInput) => tasks.transition(string(input, "id"), "retry", eventContext(input))),
227
- define("tasks.cancel", (input: OperationInput) => tasks.transition(string(input, "id"), "cancel", eventContext(input))),
228
- define("tasks.reopen", (input: OperationInput) => tasks.transition(string(input, "id"), "reopen", eventContext(input))),
240
+ define("tasks.reject", (input: OperationInput) =>
241
+ tasks.transition(string(input, "id"), "reject", eventContext(input), mutationRequest(input)),
242
+ ),
243
+ define("tasks.retry", (input: OperationInput) =>
244
+ tasks.transition(string(input, "id"), "retry", eventContext(input), mutationRequest(input)),
245
+ ),
246
+ define("tasks.cancel", (input: OperationInput) =>
247
+ tasks.transition(string(input, "id"), "cancel", eventContext(input), mutationRequest(input)),
248
+ ),
249
+ define("tasks.reopen", (input: OperationInput) =>
250
+ tasks.transition(string(input, "id"), "reopen", eventContext(input), mutationRequest(input)),
251
+ ),
229
252
  define("tasks.cancel_subtree", (input: OperationInput) => tasks.cancelSubtree(string(input, "id"), eventContext(input))),
230
253
  define("tasks.depend", (input: OperationInput) =>
231
254
  tasks.depend(string(input, "id"), string(input, "dependency_id"), eventContext(input)),
package/src/service.ts CHANGED
@@ -43,6 +43,7 @@ import { SQLiteTaskCreateRequestStore } from "./stores/sqlite-task-create-reques
43
43
  import { SQLiteTaskEventStore } from "./stores/sqlite-task-event-store.ts";
44
44
  import { SQLiteTaskFocusStore } from "./stores/sqlite-task-focus-store.ts";
45
45
  import { SQLiteTaskLeaseStore } from "./stores/sqlite-task-lease-store.ts";
46
+ import { SQLiteTaskMutationRequestStore } from "./stores/sqlite-task-mutation-request-store.ts";
46
47
  import { SQLiteTaskScopeStore } from "./stores/sqlite-task-scope-store.ts";
47
48
  import type { TaskEventStore } from "./stores/task-event-store.ts";
48
49
  import type { TaskScopeStore } from "./stores/task-scope-store.ts";
@@ -368,6 +369,7 @@ function handlers(
368
369
  "tasks.start": forwardToModule("tasks.start"),
369
370
  "tasks.submit": forwardToModule("tasks.submit"),
370
371
  "tasks.complete": forwardToModule("tasks.complete"),
372
+ "tasks.mutation_status": forwardToModule("tasks.mutation_status"),
371
373
  "tasks.run_gates": forwardToModule("tasks.run_gates"),
372
374
  "tasks.set_checklist": forwardToModule("tasks.set_checklist"),
373
375
  "tasks.set_gates": forwardToModule("tasks.set_gates"),
@@ -454,7 +456,8 @@ export function createPapyrusService(path: string): PapyrusService {
454
456
  const scopes = new SQLiteTaskScopeStore(db);
455
457
  const leases = new SQLiteTaskLeaseStore(db);
456
458
  const createRequests = new SQLiteTaskCreateRequestStore(db);
457
- const tasks = new Tasks(artifacts, gates, focus, events, scopes, leases, createRequests);
459
+ const mutationRequests = new SQLiteTaskMutationRequestStore(db);
460
+ const tasks = new Tasks(artifacts, gates, focus, events, scopes, leases, createRequests, mutationRequests);
458
461
  const noteEvents = new SQLiteNoteEventStore(db);
459
462
  const notes = new Notes(artifacts, noteEvents);
460
463
  const projections = new SQLiteGraphProjectionStore(db);
@@ -0,0 +1,98 @@
1
+ import type { Db } from "../db.ts";
2
+ import { TaskMutationPendingError, type TaskMutationRequestRecord, type TaskMutationRequestStore } from "./task-mutation-request-store.ts";
3
+
4
+ interface TaskMutationRequestRow {
5
+ request_scope: string;
6
+ idempotency_key: string;
7
+ receipt_id: string;
8
+ task_id: string | null;
9
+ operation: string;
10
+ request_hash: string;
11
+ state: "pending" | "completed";
12
+ response_json: string | null;
13
+ created_at: string;
14
+ updated_at: string;
15
+ expires_at: string;
16
+ }
17
+
18
+ function mapRow(row: TaskMutationRequestRow): TaskMutationRequestRecord {
19
+ return {
20
+ scope: row.request_scope,
21
+ key: row.idempotency_key,
22
+ receiptId: row.receipt_id,
23
+ ...(row.task_id === null ? {} : { taskId: row.task_id }),
24
+ operation: row.operation,
25
+ requestHash: row.request_hash,
26
+ state: row.state,
27
+ ...(row.response_json === null ? {} : { responseJson: row.response_json }),
28
+ createdAt: row.created_at,
29
+ updatedAt: row.updated_at,
30
+ expiresAt: row.expires_at,
31
+ };
32
+ }
33
+
34
+ export class SQLiteTaskMutationRequestStore implements TaskMutationRequestStore {
35
+ constructor(private readonly db: Db) {}
36
+
37
+ get(scope: string, key: string, now: string): TaskMutationRequestRecord | undefined {
38
+ const row = this.db
39
+ .prepare("SELECT * FROM task_mutation_requests WHERE request_scope = ? AND idempotency_key = ? AND expires_at > ?")
40
+ .get(scope, key, now) as TaskMutationRequestRow | null;
41
+ return row ? mapRow(row) : undefined;
42
+ }
43
+
44
+ findPending(taskId: string, operation: string, now: string): TaskMutationRequestRecord | undefined {
45
+ const row = this.db
46
+ .prepare(
47
+ "SELECT * FROM task_mutation_requests WHERE task_id = ? AND operation = ? AND state = 'pending' AND expires_at > ? ORDER BY created_at LIMIT 1",
48
+ )
49
+ .get(taskId, operation, now) as TaskMutationRequestRow | null;
50
+ return row ? mapRow(row) : undefined;
51
+ }
52
+
53
+ put(record: TaskMutationRequestRecord): void {
54
+ try {
55
+ this.db
56
+ .prepare(`
57
+ INSERT INTO task_mutation_requests (
58
+ request_scope, idempotency_key, receipt_id, task_id, operation, request_hash,
59
+ state, response_json, created_at, updated_at, expires_at
60
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
61
+ `)
62
+ .run(
63
+ record.scope,
64
+ record.key,
65
+ record.receiptId,
66
+ record.taskId ?? null,
67
+ record.operation,
68
+ record.requestHash,
69
+ record.state,
70
+ record.responseJson ?? null,
71
+ record.createdAt,
72
+ record.updatedAt,
73
+ record.expiresAt,
74
+ );
75
+ } catch (error) {
76
+ const pending = record.taskId ? this.findPending(record.taskId, record.operation, record.createdAt) : undefined;
77
+ if (pending) {
78
+ throw new TaskMutationPendingError(`an earlier ${record.operation} outcome is still pending`, pending.receiptId, pending.operation);
79
+ }
80
+ throw error;
81
+ }
82
+ }
83
+
84
+ complete(scope: string, key: string, responseJson: string, updatedAt: string): void {
85
+ const result = this.db
86
+ .prepare(`
87
+ UPDATE task_mutation_requests
88
+ SET state = 'completed', response_json = ?, updated_at = ?
89
+ WHERE request_scope = ? AND idempotency_key = ?
90
+ `)
91
+ .run(responseJson, updatedAt, scope, key);
92
+ if (result.changes !== 1) throw new Error("task mutation receipt not found");
93
+ }
94
+
95
+ prune(now: string): number {
96
+ return this.db.prepare("DELETE FROM task_mutation_requests WHERE expires_at <= ?").run(now).changes;
97
+ }
98
+ }
@@ -0,0 +1,89 @@
1
+ export type TaskMutationRequestState = "pending" | "completed";
2
+
3
+ export interface TaskMutationRequestRecord {
4
+ scope: string;
5
+ key: string;
6
+ receiptId: string;
7
+ taskId?: string;
8
+ operation: string;
9
+ requestHash: string;
10
+ state: TaskMutationRequestState;
11
+ responseJson?: string;
12
+ createdAt: string;
13
+ updatedAt: string;
14
+ expiresAt: string;
15
+ }
16
+
17
+ export interface TaskMutationRequestStore {
18
+ get(scope: string, key: string, now: string): TaskMutationRequestRecord | undefined;
19
+ findPending(taskId: string, operation: string, now: string): TaskMutationRequestRecord | undefined;
20
+ put(record: TaskMutationRequestRecord): void;
21
+ complete(scope: string, key: string, responseJson: string, updatedAt: string): void;
22
+ prune(now: string): number;
23
+ }
24
+
25
+ export class TaskMutationIdempotencyConflictError extends Error {}
26
+
27
+ export class TaskMutationPendingError extends Error {
28
+ constructor(
29
+ message: string,
30
+ readonly receiptId: string,
31
+ readonly operation: string,
32
+ ) {
33
+ super(message);
34
+ }
35
+ }
36
+
37
+ export class InMemoryTaskMutationRequestStore implements TaskMutationRequestStore {
38
+ private readonly records = new Map<string, TaskMutationRequestRecord>();
39
+
40
+ private recordKey(scope: string, key: string): string {
41
+ return `${scope}\u0000${key}`;
42
+ }
43
+
44
+ get(scope: string, key: string, now: string): TaskMutationRequestRecord | undefined {
45
+ const record = this.records.get(this.recordKey(scope, key));
46
+ return !record || record.expiresAt <= now ? undefined : { ...record };
47
+ }
48
+
49
+ findPending(taskId: string, operation: string, now: string): TaskMutationRequestRecord | undefined {
50
+ for (const record of this.records.values()) {
51
+ if (record.taskId === taskId && record.operation === operation && record.state === "pending" && record.expiresAt > now) {
52
+ return { ...record };
53
+ }
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ put(record: TaskMutationRequestRecord): void {
59
+ if (record.state === "pending" && record.taskId) {
60
+ const existing = this.findPending(record.taskId, record.operation, record.createdAt);
61
+ if (existing) {
62
+ throw new TaskMutationPendingError(
63
+ `an earlier ${record.operation} outcome is still pending`,
64
+ existing.receiptId,
65
+ existing.operation,
66
+ );
67
+ }
68
+ }
69
+ this.records.set(this.recordKey(record.scope, record.key), { ...record });
70
+ }
71
+
72
+ complete(scope: string, key: string, responseJson: string, updatedAt: string): void {
73
+ const recordKey = this.recordKey(scope, key);
74
+ const record = this.records.get(recordKey);
75
+ if (!record) throw new Error("task mutation receipt not found");
76
+ this.records.set(recordKey, { ...record, state: "completed", responseJson, updatedAt });
77
+ }
78
+
79
+ prune(now: string): number {
80
+ let removed = 0;
81
+ for (const [key, record] of this.records) {
82
+ if (record.expiresAt <= now) {
83
+ this.records.delete(key);
84
+ removed += 1;
85
+ }
86
+ }
87
+ return removed;
88
+ }
89
+ }