@danypops/papyrus 0.53.2 → 0.54.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.53.2",
3
+ "version": "0.54.2",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/constants.ts CHANGED
@@ -1,3 +1,10 @@
1
+ /** This package's own Vehicle manifest identity name -- the exact string createPapyrusVehicleRegistry
2
+ * constructs its VehicleRegistry with (handlers/registry.ts). Exported so a consumer that needs to
3
+ * name "the Vehicle behind Papyrus" (e.g. pi-papyrus's own persistent widget headers, via
4
+ * @danypops/vehicle-client-pi's vehicleWidgetTitle) has one real source instead of a second
5
+ * hand-typed "papyrus" literal that could silently drift from the registry's own. */
6
+ export const PAPYRUS_VEHICLE_NAME = "papyrus";
7
+
1
8
  /** Long-running daemon transport and state. */
2
9
  export const DAEMON_HOST = "127.0.0.1";
3
10
  export const DAEMON_PORT_FILE = "port";
@@ -141,6 +141,24 @@ export function normalizeTaskHistoryQuery(
141
141
  return { limit, direction: query.direction ?? "desc", ...(query.cursor === undefined ? {} : { cursor: query.cursor }) };
142
142
  }
143
143
 
144
+ /**
145
+ * Validates just the caller-supplied context fields (sessionId/reason) that are already fully
146
+ * known BEFORE a mutation method does anything else -- unlike actor/source (always filled with
147
+ * defaults by appendEvent, never caller-controlled in practice) or evidence (often not known
148
+ * until real work, e.g. gate results, has already happened). Exported specifically so a mutation
149
+ * method can call it BEFORE reserving an idempotency receipt (see TaskMutationCoordinator.prepare's
150
+ * own `validate` hook) rather than only discovering an invalid reason deep inside events.atomic(),
151
+ * after a receipt was already durably written as pending -- a real incident (task a54f0649): a
152
+ * validation failure that fires only after the reserve leaves that receipt permanently stuck,
153
+ * since nothing else in the call ever reaches the code path that marks it complete.
154
+ */
155
+ export function validateEventContext(context: Pick<TaskEventContext, "sessionId" | "reason">): void {
156
+ if (context.sessionId !== undefined && context.sessionId.length > TASK_EVENT_ACTOR_MAX_LENGTH)
157
+ throw new Error(`sessionId cannot exceed ${TASK_EVENT_ACTOR_MAX_LENGTH} characters`);
158
+ if (context.reason !== undefined && context.reason.length > TASK_EVENT_REASON_MAX_LENGTH)
159
+ throw new Error(`reason cannot exceed ${TASK_EVENT_REASON_MAX_LENGTH} characters`);
160
+ }
161
+
144
162
  export function validateTaskEvent(event: AppendTaskEvent): AppendTaskEvent {
145
163
  for (const [field, value] of [
146
164
  ["actor", event.actor],
@@ -149,10 +167,7 @@ export function validateTaskEvent(event: AppendTaskEvent): AppendTaskEvent {
149
167
  if (!value || value.length > TASK_EVENT_ACTOR_MAX_LENGTH)
150
168
  throw new Error(`${field} must be between 1 and ${TASK_EVENT_ACTOR_MAX_LENGTH} characters`);
151
169
  }
152
- if (event.sessionId !== undefined && event.sessionId.length > TASK_EVENT_ACTOR_MAX_LENGTH)
153
- throw new Error(`sessionId cannot exceed ${TASK_EVENT_ACTOR_MAX_LENGTH} characters`);
154
- if (event.reason !== undefined && event.reason.length > TASK_EVENT_REASON_MAX_LENGTH)
155
- throw new Error(`reason cannot exceed ${TASK_EVENT_REASON_MAX_LENGTH} characters`);
170
+ validateEventContext(event);
156
171
  if (event.evidence !== undefined && new TextEncoder().encode(JSON.stringify(event.evidence)).byteLength > TASK_EVENT_MAX_EVIDENCE_BYTES) {
157
172
  throw new Error(`task event evidence cannot exceed ${TASK_EVENT_MAX_EVIDENCE_BYTES} bytes`);
158
173
  }
@@ -10,6 +10,7 @@ import type { ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
10
10
  import type { ArtifactStore } from "../artifact/artifact-store.ts";
11
11
  import type { ArtifactTrashStore } from "../artifact/artifact-trash-store.ts";
12
12
  import type { AuthorityRegistry } from "../authority-registry.ts";
13
+ import { PAPYRUS_VEHICLE_NAME } from "../constants.ts";
13
14
  import type { Discussions } from "../discussion/discussion-service.ts";
14
15
  import type { Notes } from "../note/note-service.ts";
15
16
  import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
@@ -41,7 +42,7 @@ export interface PapyrusVehicleDeps {
41
42
 
42
43
  export function createPapyrusVehicleRegistry(deps: PapyrusVehicleDeps): VehicleRegistry {
43
44
  const registry = new VehicleRegistry({
44
- name: "papyrus",
45
+ name: PAPYRUS_VEHICLE_NAME,
45
46
  version: "1.0.0",
46
47
  description: "Papyrus's graph-artifact domains, one honest operation per real action.",
47
48
  });
package/src/ops.ts CHANGED
@@ -637,18 +637,35 @@ function gateOutputTail(text: string): string {
637
637
  return text.length > GATE_OUTPUT_LIMIT ? text.slice(-GATE_OUTPUT_LIMIT) : text;
638
638
  }
639
639
 
640
+ /**
641
+ * The one place "did this process gate pass, and what should its display output say" is decided,
642
+ * shared by the sync (runProcessGateSync) and async (executeGateCommand's caller) process-gate
643
+ * runners -- previously two hand-copied inline checks that already had to be fixed twice, by
644
+ * hand, more than once (gate.expect seeing stderr too; GATE_OUTPUT_LIMIT's truncation direction).
645
+ * `matchable` must be the FULL captured output (bounded only by GATE_MAX_BUFFER_BYTES / Node's own
646
+ * spawnSync maxBuffer, never GATE_OUTPUT_LIMIT), so gate.expect always sees the whole run, never
647
+ * the truncated display copy `output` becomes.
648
+ */
649
+ function evaluateProcessGateResult(gate: Gate, code: number | null, matchable: string): { passed: boolean; output: string } {
650
+ const exitedZero = code === 0;
651
+ return {
652
+ passed: exitedZero && (gate.expect ? matchable.includes(gate.expect) : true),
653
+ output: gateOutputTail(matchable) || (exitedZero ? "ok" : `command exited with code ${code}`),
654
+ };
655
+ }
656
+
657
+ /** The other shared branch: a literal spawn-level failure (command not found, spawnSync's own maxBuffer exceeded, etc.) -- distinct from a process that ran and exited non-zero, which evaluateProcessGateResult above handles. Identical treatment on both the sync and async paths: the raw error message, tail-truncated like any other gate output. */
658
+ function spawnErrorGateResult(gate: Gate, error: Error): GateResult {
659
+ return { gate, passed: false, output: gateOutputTail(error.message) };
660
+ }
661
+
640
662
  function runProcessGateSync(gate: Gate, cwd?: string): GateResult {
641
663
  const { spawnSync } = require_("node:child_process");
642
664
  const { command, timeout } = processGateCommand(gate);
643
665
  const result = spawnSync(command, { shell: true, encoding: "utf-8", timeout, ...(cwd ? { cwd } : {}) });
644
- if (result.error) return { gate, passed: false, output: gateOutputTail(result.error.message) };
666
+ if (result.error) return spawnErrorGateResult(gate, result.error);
645
667
  const combined = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
646
- const passed = result.status === 0 && (gate.expect ? combined.includes(gate.expect) : true);
647
- return {
648
- gate,
649
- passed,
650
- output: gateOutputTail(combined) || (result.status === 0 ? "ok" : `command exited with code ${result.status}`),
651
- };
668
+ return { gate, ...evaluateProcessGateResult(gate, result.status, combined) };
652
669
  }
653
670
 
654
671
  export function runGates(db: Db, artifactId: string, options: GateRunOptions = {}): GateResult[] {
@@ -670,11 +687,7 @@ export function runGates(db: Db, artifactId: string, options: GateRunOptions = {
670
687
  * indefinitely after Papyrus considers the gate "timed out". Spawning detached (its own
671
688
  * process group) and killing the negated pid on our own timer reaches the whole tree.
672
689
  */
673
- function executeGateCommand(
674
- command: string,
675
- timeout: number,
676
- cwd?: string,
677
- ): Promise<{ passed: boolean; output: string; matchable: string }> {
690
+ function executeGateCommand(gate: Gate, command: string, timeout: number, cwd?: string): Promise<GateResult> {
678
691
  // `spawn(..., { shell: true, detached: true })` instead of the `exec()` convenience wrapper:
679
692
  // `detached` (needed to make the shell the leader of its own process group, so the negated pid
680
693
  // below reaches every descendant, not just the shell) is not part of Node's `exec()`/
@@ -697,22 +710,15 @@ function executeGateCommand(
697
710
  child.stdout?.on("data", append);
698
711
  child.stderr?.on("data", append);
699
712
 
700
- const finish = (result: { passed: boolean; output: string; matchable: string }): void => {
713
+ const finish = (result: GateResult): void => {
701
714
  if (settled) return;
702
715
  settled = true;
703
716
  clearTimeout(timer);
704
717
  resolve(result);
705
718
  };
706
719
 
707
- child.on("error", (error) => finish({ passed: false, output: gateOutputTail(error.message), matchable: error.message }));
708
- child.on("close", (code) => {
709
- // `matchable` carries the full (GATE_MAX_BUFFER_BYTES-bounded) buffer so the caller's
710
- // gate.expect substring check sees the whole run, regardless of GATE_OUTPUT_LIMIT --
711
- // `output` (below) is only ever the display copy.
712
- const full = buffered.trim();
713
- const output = gateOutputTail(full);
714
- finish({ passed: code === 0, output: output || (code === 0 ? "ok" : `command exited with code ${code}`), matchable: full });
715
- });
720
+ child.on("error", (error) => finish(spawnErrorGateResult(gate, error)));
721
+ child.on("close", (code) => finish({ gate, ...evaluateProcessGateResult(gate, code, buffered.trim()) }));
716
722
 
717
723
  const timer = setTimeout(() => {
718
724
  if (settled) return;
@@ -723,7 +729,7 @@ function executeGateCommand(
723
729
  child.kill("SIGKILL");
724
730
  }
725
731
  }
726
- finish({ passed: false, output: `gate command timed out after ${timeout}ms`, matchable: "" });
732
+ finish({ gate, passed: false, output: `gate command timed out after ${timeout}ms` });
727
733
  }, timeout);
728
734
  });
729
735
  }
@@ -761,12 +767,7 @@ export async function runGatesAsync(db: Db, artifactId: string, options: GateRun
761
767
  if (gate.type === "command" || gate.type === "test") {
762
768
  const { command, timeout: configuredTimeout } = processGateCommand(gate);
763
769
  const timeout = remainingMs === undefined ? configuredTimeout : Math.max(1, Math.min(configuredTimeout, remainingMs));
764
- const executed = await executeGateCommand(command, timeout, options.cwd);
765
- results.push({
766
- gate,
767
- passed: executed.passed && (gate.expect ? executed.matchable.includes(gate.expect) : true),
768
- output: executed.output,
769
- });
770
+ results.push(await executeGateCommand(gate, command, timeout, options.cwd));
770
771
  } else {
771
772
  results.push(runNonProcessGate(gate));
772
773
  }
@@ -0,0 +1,52 @@
1
+ import type { Artifact } from "../artifact/artifact.ts";
2
+ import type { TaskLease, TaskLeaseView } from "../domain/task-lease.ts";
3
+ import type { TaskLeaseStore } from "../stores/task-lease-store.ts";
4
+
5
+ /**
6
+ * Task lease management (claim/heartbeat/release/get/reap), split out of the Tasks god class as
7
+ * part of a SOLID-audit-driven decomposition (see task b51419a0). A lease is orthogonal to
8
+ * lifecycle and Focus -- claiming a task does not start it, and does not require it to be
9
+ * Focused -- so its own concern (a single active worker per task, TTL-based) has nothing to do
10
+ * with status transitions, idempotency receipts, or checklist review, the other concerns that
11
+ * were previously interleaved with it in one class.
12
+ */
13
+ export class TaskLeaseCoordinator {
14
+ constructor(
15
+ private readonly leases: TaskLeaseStore,
16
+ /** Delegates to Tasks.require() so lease methods get the identical not-found/wrong-kind checks every other Tasks method already enforces, without duplicating that logic here. */
17
+ private readonly requireTask: (id: string) => Artifact,
18
+ ) {}
19
+
20
+ private present(lease: TaskLease): TaskLeaseView {
21
+ const task = this.requireTask(lease.taskId);
22
+ const { taskId: _taskId, ...details } = lease;
23
+ return { taskName: task.alias, taskTitle: task.title, ...details };
24
+ }
25
+
26
+ /** A lease is orthogonal to lifecycle and Focus: claiming a task does not start it, and does not require it to be Focused. */
27
+ claim(id: string, owner: string, ttlMs?: number, note?: string): TaskLeaseView {
28
+ this.requireTask(id);
29
+ return this.present(this.leases.claim(id, owner, ttlMs, note));
30
+ }
31
+
32
+ heartbeat(id: string, owner: string, token: string, ttlMs?: number): TaskLeaseView {
33
+ this.requireTask(id);
34
+ return this.present(this.leases.heartbeat(id, owner, token, ttlMs));
35
+ }
36
+
37
+ /** Idempotent for an already-absent or already-expired lease, matching undepend/uncontain's precedent -- never throws merely because there was nothing left to release. */
38
+ release(id: string, owner: string, token: string): { released: boolean } {
39
+ this.requireTask(id);
40
+ return this.leases.release(id, owner, token);
41
+ }
42
+
43
+ get(id: string): TaskLeaseView | undefined {
44
+ this.requireTask(id);
45
+ const lease = this.leases.get(id);
46
+ return lease ? this.present(lease) : undefined;
47
+ }
48
+
49
+ reapStale(now: () => string = () => new Date().toISOString()): number {
50
+ return this.leases.reapExpired(now());
51
+ }
52
+ }
@@ -0,0 +1,160 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { ArtifactStore } from "../artifact/artifact-store.ts";
3
+ import { TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH, TASK_MUTATION_IDEMPOTENCY_RETENTION_MS } from "../constants.ts";
4
+ import {
5
+ TaskMutationIdempotencyConflictError,
6
+ TaskMutationPendingError,
7
+ type TaskMutationRequestRecord,
8
+ type TaskMutationRequestStore,
9
+ } from "../stores/task-mutation-request-store.ts";
10
+
11
+ export class TaskMutationReceiptNotFoundError extends Error {}
12
+
13
+ export interface TaskMutationRequestContext {
14
+ key?: string;
15
+ caller?: string;
16
+ }
17
+
18
+ export interface TaskMutationReceiptView {
19
+ receiptId: string;
20
+ operation: string;
21
+ state: "pending" | "completed";
22
+ taskName?: string;
23
+ taskTitle?: string;
24
+ taskStatus?: string;
25
+ result?: unknown;
26
+ createdAt: string;
27
+ updatedAt: string;
28
+ expiresAt: string;
29
+ }
30
+
31
+ function canonicalJson(value: unknown): string {
32
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
33
+ if (typeof value === "object" && value !== null) {
34
+ return `{${Object.entries(value)
35
+ .filter(([, entry]) => entry !== undefined)
36
+ .sort(([left], [right]) => left.localeCompare(right))
37
+ .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`)
38
+ .join(",")}}`;
39
+ }
40
+ return JSON.stringify(value) ?? "null";
41
+ }
42
+
43
+ /**
44
+ * Idempotency-key-backed mutation receipt plumbing, split out of the Tasks god class as part of
45
+ * a SOLID-audit-driven decomposition (see task b51419a0). Owns reserving a "pending" receipt
46
+ * before a real mutation runs, replaying an already-completed one, rejecting a genuinely
47
+ * different payload reused under the same key, and rejecting a NEW attempt against a
48
+ * task+operation that already has one in flight.
49
+ *
50
+ * `validate`, when supplied to prepare(), runs before anything else -- including before the
51
+ * existing/replay lookup -- specifically so a caller-supplied validation failure (e.g. an
52
+ * over-length `reason`) can never leave a receipt reserved with no way to ever mark it complete.
53
+ * This is the direct fix for a real incident (task a54f0649, discovered live completing task
54
+ * d0eb81b7): validation previously ran deep inside the CALLER's own atomic block (appendEvent's
55
+ * own validateTaskEvent), strictly AFTER prepare()'s reserving call had already durably written
56
+ * the receipt as pending. Once that validation threw, nothing downstream ever reached the code
57
+ * path that marks a receipt complete, permanently stranding it -- and since the pending-mutation
58
+ * lock is keyed on (taskId, operation) rather than the idempotency key, that stuck receipt then
59
+ * blocked every subsequent attempt on the same task+operation, under ANY key, until the record's
60
+ * 7-day retention window expired. No self-service recovery existed; the live incident required a
61
+ * direct database row deletion. Every caller that can determine its own event-context validity up
62
+ * front (reason/sessionId length, at minimum) should now pass a `validate` callback here instead
63
+ * of only validating once real mutation work is already underway.
64
+ */
65
+ export class TaskMutationCoordinator {
66
+ constructor(
67
+ private readonly mutationRequests: TaskMutationRequestStore,
68
+ private readonly artifacts: ArtifactStore,
69
+ ) {}
70
+
71
+ prepare<Result>(
72
+ operation: string,
73
+ taskId: string | undefined,
74
+ payload: unknown,
75
+ request: TaskMutationRequestContext,
76
+ reserve = true,
77
+ validate?: () => void,
78
+ ): { record?: TaskMutationRequestRecord; replay?: Result; pending?: boolean } {
79
+ validate?.();
80
+ const key = request.key?.trim();
81
+ if (request.key !== undefined && (!key || key.length > TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH)) {
82
+ throw new Error(`idempotency key must be between 1 and ${TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH} characters`);
83
+ }
84
+ if (!key) return {};
85
+ const now = new Date().toISOString();
86
+ const scope = request.caller?.trim() || "anonymous";
87
+ const requestHash = createHash("sha256").update(canonicalJson({ operation, taskId, payload })).digest("hex");
88
+ this.mutationRequests.prune(now);
89
+ const existing = this.mutationRequests.get(scope, key, now);
90
+ if (existing) {
91
+ if (existing.requestHash !== requestHash) {
92
+ throw new TaskMutationIdempotencyConflictError(`idempotency key "${key}" was already used with a different mutation payload`);
93
+ }
94
+ if (existing.state === "completed" && existing.responseJson !== undefined) {
95
+ const replay = JSON.parse(existing.responseJson) as Result;
96
+ return {
97
+ record: existing,
98
+ replay:
99
+ typeof replay === "object" && replay !== null && "changed" in replay
100
+ ? ({ ...replay, changed: false, replayed: true } as Result)
101
+ : replay,
102
+ };
103
+ }
104
+ return { record: existing, pending: true };
105
+ }
106
+ if (!reserve) return {};
107
+ const record: TaskMutationRequestRecord = {
108
+ scope,
109
+ key,
110
+ receiptId: crypto.randomUUID(),
111
+ ...(taskId === undefined ? {} : { taskId }),
112
+ operation,
113
+ requestHash,
114
+ state: "pending",
115
+ createdAt: now,
116
+ updatedAt: now,
117
+ expiresAt: new Date(Date.parse(now) + TASK_MUTATION_IDEMPOTENCY_RETENTION_MS).toISOString(),
118
+ };
119
+ this.mutationRequests.put(record);
120
+ return { record };
121
+ }
122
+
123
+ rejectDifferentPending(taskId: string, operation: string, inspectionPending: boolean): void {
124
+ if (inspectionPending) return;
125
+ const pending = this.mutationRequests.findPending(taskId, operation, new Date().toISOString());
126
+ if (!pending) return;
127
+ throw new TaskMutationPendingError(
128
+ `an earlier ${operation} outcome is still pending; inspect tasks.mutation_status with its original idempotency_key before retrying`,
129
+ pending.receiptId,
130
+ pending.operation,
131
+ );
132
+ }
133
+
134
+ complete<Result>(record: TaskMutationRequestRecord | undefined, result: Result): Result {
135
+ if (!record) return result;
136
+ this.mutationRequests.complete(record.scope, record.key, JSON.stringify(result), new Date().toISOString());
137
+ return result;
138
+ }
139
+
140
+ status(keyInput: string, caller?: string): TaskMutationReceiptView {
141
+ const key = keyInput.trim();
142
+ if (!key || key.length > TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH) {
143
+ throw new Error(`idempotency key must be between 1 and ${TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH} characters`);
144
+ }
145
+ const now = new Date().toISOString();
146
+ const record = this.mutationRequests.get(caller?.trim() || "anonymous", key, now);
147
+ if (!record) throw new TaskMutationReceiptNotFoundError("no retained task mutation receipt exists for this idempotency key");
148
+ const task = record.taskId ? this.artifacts.get(record.taskId) : null;
149
+ return {
150
+ receiptId: record.receiptId,
151
+ operation: record.operation,
152
+ state: record.state,
153
+ ...(task?.kind === "task" ? { taskName: task.alias, taskTitle: task.title, taskStatus: task.status } : {}),
154
+ ...(record.responseJson === undefined ? {} : { result: JSON.parse(record.responseJson) as unknown }),
155
+ createdAt: record.createdAt,
156
+ updatedAt: record.updatedAt,
157
+ expiresAt: record.expiresAt,
158
+ };
159
+ }
160
+ }
@@ -12,8 +12,6 @@ import {
12
12
  TASK_FOCUS_STALE_AFTER_MS,
13
13
  TASK_LABEL_MAX_COUNT,
14
14
  TASK_LABEL_MAX_LENGTH,
15
- TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH,
16
- TASK_MUTATION_IDEMPOTENCY_RETENTION_MS,
17
15
  TASK_PROJECT_LIST_MAX_RESULTS,
18
16
  TASK_SCOPE_MAX_TASKS,
19
17
  TASK_TITLE_MAX_LENGTH,
@@ -31,7 +29,8 @@ import type {
31
29
  TaskHistoryQuery,
32
30
  TaskLifecycleStatus,
33
31
  } from "../domain/task-event.ts";
34
- import type { TaskLease, TaskLeaseView } from "../domain/task-lease.ts";
32
+ import { validateEventContext } from "../domain/task-event.ts";
33
+ import type { TaskLeaseView } from "../domain/task-lease.ts";
35
34
  import {
36
35
  normalizeProjectRoot,
37
36
  type RegisterTaskProjectInput,
@@ -53,13 +52,21 @@ import { InMemoryTaskFocusStore, type TaskFocusStatus, type TaskFocusStore } fro
53
52
  import { InMemoryTaskLeaseStore, type TaskLeaseStore } from "../stores/task-lease-store.ts";
54
53
  import {
55
54
  InMemoryTaskMutationRequestStore,
56
- TaskMutationIdempotencyConflictError,
57
55
  TaskMutationPendingError,
58
56
  type TaskMutationRequestRecord,
59
57
  type TaskMutationRequestStore,
60
58
  } from "../stores/task-mutation-request-store.ts";
61
59
  import { InMemoryTaskScopeStore, type TaskScopeStore } from "../stores/task-scope-store.ts";
62
60
  import { assertDependencyEdgeAllowed, TaskExecutionBoundExceededError } from "./task-execution.ts";
61
+ import { TaskLeaseCoordinator } from "./task-lease-coordinator.ts";
62
+ import {
63
+ TaskMutationCoordinator,
64
+ TaskMutationReceiptNotFoundError,
65
+ type TaskMutationReceiptView,
66
+ type TaskMutationRequestContext,
67
+ } from "./task-mutation-coordinator.ts";
68
+
69
+ export { TaskMutationReceiptNotFoundError, type TaskMutationReceiptView, type TaskMutationRequestContext };
63
70
 
64
71
  export interface UpdateTaskInput {
65
72
  title?: string;
@@ -85,7 +92,6 @@ export type TaskStatus = TaskLifecycleStatus;
85
92
 
86
93
  export class TaskProjectNotFoundError extends Error {}
87
94
  export class TaskProjectAmbiguousError extends Error {}
88
- export class TaskMutationReceiptNotFoundError extends Error {}
89
95
 
90
96
  export class TaskInvalidTransitionError extends Error {
91
97
  constructor(
@@ -99,11 +105,6 @@ export class TaskInvalidTransitionError extends Error {
99
105
  }
100
106
  }
101
107
 
102
- export interface TaskMutationRequestContext {
103
- key?: string;
104
- caller?: string;
105
- }
106
-
107
108
  export interface TaskMutationMetadata {
108
109
  changed: boolean;
109
110
  operation: string;
@@ -115,19 +116,6 @@ export interface TaskMutationMetadata {
115
116
 
116
117
  export type TaskLifecycleMutationResult = Artifact & TaskMutationMetadata;
117
118
 
118
- export interface TaskMutationReceiptView {
119
- receiptId: string;
120
- operation: string;
121
- state: "pending" | "completed";
122
- taskName?: string;
123
- taskTitle?: string;
124
- taskStatus?: string;
125
- result?: unknown;
126
- createdAt: string;
127
- updatedAt: string;
128
- expiresAt: string;
129
- }
130
-
131
119
  export interface CreateTaskRequestContext {
132
120
  key?: string;
133
121
  caller?: string;
@@ -243,9 +231,14 @@ export class Tasks {
243
231
  private readonly leases: TaskLeaseStore = new InMemoryTaskLeaseStore(),
244
232
  private readonly createRequests: TaskCreateRequestStore = new InMemoryTaskCreateRequestStore(),
245
233
  private readonly mutationRequests: TaskMutationRequestStore = new InMemoryTaskMutationRequestStore(),
246
- ) {}
234
+ ) {
235
+ this.leaseCoordinator = new TaskLeaseCoordinator(this.leases, (id) => this.require(id));
236
+ this.mutationCoordinator = new TaskMutationCoordinator(this.mutationRequests, this.artifacts);
237
+ }
247
238
 
248
239
  private readonly completionFlights = new Map<string, Promise<TaskCompletion>>();
240
+ private readonly leaseCoordinator: TaskLeaseCoordinator;
241
+ private readonly mutationCoordinator: TaskMutationCoordinator;
249
242
 
250
243
  private require(id: string): Artifact {
251
244
  const artifact = this.artifacts.get(id);
@@ -603,7 +596,9 @@ export class Tasks {
603
596
  "Focus a non-terminal task before pausing; do not blindly retry pause.",
604
597
  );
605
598
  }
606
- const prepared = inspection.pending ? inspection : this.prepareMutation<TaskFocusMutationResult>("pause", undefined, context, request);
599
+ const prepared = inspection.pending
600
+ ? inspection
601
+ : this.prepareMutation<TaskFocusMutationResult>("pause", undefined, context, request, true, () => validateEventContext(context));
607
602
  if (focus.status === "paused") {
608
603
  return this.completeMutation(prepared.record, {
609
604
  ...focus,
@@ -646,7 +641,7 @@ export class Tasks {
646
641
  }
647
642
  const prepared = inspection.pending
648
643
  ? inspection
649
- : this.prepareMutation<TaskFocusMutationResult>("unpause", undefined, context, request);
644
+ : this.prepareMutation<TaskFocusMutationResult>("unpause", undefined, context, request, true, () => validateEventContext(context));
650
645
  if (focus.status === "active") {
651
646
  return this.completeMutation(prepared.record, {
652
647
  ...focus,
@@ -695,37 +690,26 @@ export class Tasks {
695
690
  return this.focusStore.reapStale(cutoff);
696
691
  }
697
692
 
698
- private presentLease(lease: TaskLease): TaskLeaseView {
699
- const task = this.require(lease.taskId);
700
- const { taskId: _taskId, ...details } = lease;
701
- return { taskName: task.alias, taskTitle: task.title, ...details };
702
- }
703
-
704
693
  /** A lease is orthogonal to lifecycle and Focus: claiming a task does not start it, and does not require it to be Focused. */
705
694
  claimLease(id: string, owner: string, ttlMs?: number, note?: string): TaskLeaseView {
706
- this.require(id);
707
- return this.presentLease(this.leases.claim(id, owner, ttlMs, note));
695
+ return this.leaseCoordinator.claim(id, owner, ttlMs, note);
708
696
  }
709
697
 
710
698
  heartbeatLease(id: string, owner: string, token: string, ttlMs?: number): TaskLeaseView {
711
- this.require(id);
712
- return this.presentLease(this.leases.heartbeat(id, owner, token, ttlMs));
699
+ return this.leaseCoordinator.heartbeat(id, owner, token, ttlMs);
713
700
  }
714
701
 
715
702
  /** Idempotent for an already-absent or already-expired lease, matching undepend/uncontain's precedent -- never throws merely because there was nothing left to release. */
716
703
  releaseLease(id: string, owner: string, token: string): { released: boolean } {
717
- this.require(id);
718
- return this.leases.release(id, owner, token);
704
+ return this.leaseCoordinator.release(id, owner, token);
719
705
  }
720
706
 
721
707
  getLease(id: string): TaskLeaseView | undefined {
722
- this.require(id);
723
- const lease = this.leases.get(id);
724
- return lease ? this.presentLease(lease) : undefined;
708
+ return this.leaseCoordinator.get(id);
725
709
  }
726
710
 
727
711
  reapStaleLeases(now: () => string = () => new Date().toISOString()): number {
728
- return this.leases.reapExpired(now());
712
+ return this.leaseCoordinator.reapStale(now);
729
713
  }
730
714
 
731
715
  private allowedLifecycleActions(status: string): string[] {
@@ -742,86 +726,21 @@ export class Tasks {
742
726
  payload: unknown,
743
727
  request: TaskMutationRequestContext,
744
728
  reserve = true,
729
+ validate?: () => void,
745
730
  ): { record?: TaskMutationRequestRecord; replay?: Result; pending?: boolean } {
746
- const key = request.key?.trim();
747
- if (request.key !== undefined && (!key || key.length > TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH)) {
748
- throw new Error(`idempotency key must be between 1 and ${TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH} characters`);
749
- }
750
- if (!key) return {};
751
- const now = new Date().toISOString();
752
- const scope = request.caller?.trim() || "anonymous";
753
- const requestHash = createHash("sha256").update(canonicalJson({ operation, taskId, payload })).digest("hex");
754
- this.mutationRequests.prune(now);
755
- const existing = this.mutationRequests.get(scope, key, now);
756
- if (existing) {
757
- if (existing.requestHash !== requestHash) {
758
- throw new TaskMutationIdempotencyConflictError(`idempotency key "${key}" was already used with a different mutation payload`);
759
- }
760
- if (existing.state === "completed" && existing.responseJson !== undefined) {
761
- const replay = JSON.parse(existing.responseJson) as Result;
762
- return {
763
- record: existing,
764
- replay:
765
- typeof replay === "object" && replay !== null && "changed" in replay
766
- ? ({ ...replay, changed: false, replayed: true } as Result)
767
- : replay,
768
- };
769
- }
770
- return { record: existing, pending: true };
771
- }
772
- if (!reserve) return {};
773
- const record: TaskMutationRequestRecord = {
774
- scope,
775
- key,
776
- receiptId: crypto.randomUUID(),
777
- ...(taskId === undefined ? {} : { taskId }),
778
- operation,
779
- requestHash,
780
- state: "pending",
781
- createdAt: now,
782
- updatedAt: now,
783
- expiresAt: new Date(Date.parse(now) + TASK_MUTATION_IDEMPOTENCY_RETENTION_MS).toISOString(),
784
- };
785
- this.mutationRequests.put(record);
786
- return { record };
731
+ return this.mutationCoordinator.prepare<Result>(operation, taskId, payload, request, reserve, validate);
787
732
  }
788
733
 
789
734
  private rejectDifferentPendingMutation(taskId: string, operation: string, inspectionPending: boolean): void {
790
- if (inspectionPending) return;
791
- const pending = this.mutationRequests.findPending(taskId, operation, new Date().toISOString());
792
- if (!pending) return;
793
- throw new TaskMutationPendingError(
794
- `an earlier ${operation} outcome is still pending; inspect tasks.mutation_status with its original idempotency_key before retrying`,
795
- pending.receiptId,
796
- pending.operation,
797
- );
735
+ this.mutationCoordinator.rejectDifferentPending(taskId, operation, inspectionPending);
798
736
  }
799
737
 
800
738
  private completeMutation<Result>(record: TaskMutationRequestRecord | undefined, result: Result): Result {
801
- if (!record) return result;
802
- this.mutationRequests.complete(record.scope, record.key, JSON.stringify(result), new Date().toISOString());
803
- return result;
739
+ return this.mutationCoordinator.complete(record, result);
804
740
  }
805
741
 
806
742
  mutationStatus(keyInput: string, caller?: string): TaskMutationReceiptView {
807
- const key = keyInput.trim();
808
- if (!key || key.length > TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH) {
809
- throw new Error(`idempotency key must be between 1 and ${TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH} characters`);
810
- }
811
- const now = new Date().toISOString();
812
- const record = this.mutationRequests.get(caller?.trim() || "anonymous", key, now);
813
- if (!record) throw new TaskMutationReceiptNotFoundError("no retained task mutation receipt exists for this idempotency key");
814
- const task = record.taskId ? this.artifacts.get(record.taskId) : null;
815
- return {
816
- receiptId: record.receiptId,
817
- operation: record.operation,
818
- state: record.state,
819
- ...(task?.kind === "task" ? { taskName: task.alias, taskTitle: task.title, taskStatus: task.status } : {}),
820
- ...(record.responseJson === undefined ? {} : { result: JSON.parse(record.responseJson) as unknown }),
821
- createdAt: record.createdAt,
822
- updatedAt: record.updatedAt,
823
- expiresAt: record.expiresAt,
824
- };
743
+ return this.mutationCoordinator.status(keyInput, caller);
825
744
  }
826
745
 
827
746
  transition(
@@ -854,7 +773,9 @@ export class Tasks {
854
773
  );
855
774
  }
856
775
  }
857
- const prepared = inspection.pending ? inspection : this.prepareMutation<TaskLifecycleMutationResult>(action, id, context, request);
776
+ const prepared = inspection.pending
777
+ ? inspection
778
+ : this.prepareMutation<TaskLifecycleMutationResult>(action, id, context, request, true, () => validateEventContext(context));
858
779
  if (task.status === intendedStatus) {
859
780
  return this.completeMutation(prepared.record, {
860
781
  ...this.show(id),
@@ -946,7 +867,9 @@ export class Tasks {
946
867
  }
947
868
  if (task.status !== "review" && task.status !== "done") this.throwInvalidCompletion(task.status);
948
869
  if (task.status === "review") this.requireNotBlocked(task);
949
- const prepared = this.prepareMutation<TaskCompletion>("complete", id, { context, options }, request);
870
+ const prepared = this.prepareMutation<TaskCompletion>("complete", id, { context, options }, request, true, () =>
871
+ validateEventContext(context),
872
+ );
950
873
  if (task.status === "done") return this.completeMutation(prepared.record, this.completedNoop(id, context, prepared.record));
951
874
  const attemptId = prepared.record?.receiptId ?? crypto.randomUUID();
952
875
  this.events.atomic(() =>
@@ -983,7 +906,9 @@ export class Tasks {
983
906
  }
984
907
  if (task.status !== "review" && task.status !== "done") this.throwInvalidCompletion(task.status);
985
908
  if (task.status === "review") this.requireNotBlocked(task);
986
- const prepared = this.prepareMutation<TaskCompletion>("complete", id, { context, options }, request);
909
+ const prepared = this.prepareMutation<TaskCompletion>("complete", id, { context, options }, request, true, () =>
910
+ validateEventContext(context),
911
+ );
987
912
  if (task.status === "done") return this.completeMutation(prepared.record, this.completedNoop(id, context, prepared.record));
988
913
  const attemptId = prepared.record?.receiptId ?? crypto.randomUUID();
989
914
  this.events.atomic(() =>