@danypops/papyrus 0.53.1 → 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 +1 -1
- package/src/constants.ts +7 -0
- package/src/daemon/daemon.ts +17 -1
- package/src/domain/task-event.ts +19 -4
- package/src/handlers/registry.ts +2 -1
- package/src/ops.ts +30 -29
- package/src/task/task-lease-coordinator.ts +52 -0
- package/src/task/task-mutation-coordinator.ts +160 -0
- package/src/task/task-service.ts +40 -115
package/package.json
CHANGED
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";
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -42,6 +42,10 @@ const TASK_READ_ONLY_OPERATIONS = new Set([
|
|
|
42
42
|
"tasks.show",
|
|
43
43
|
]);
|
|
44
44
|
|
|
45
|
+
/** Matches @danypops/vehicle-server's own STREAMING_IDLE_TIMEOUT_S (daemon.ts) -- see this file's
|
|
46
|
+
* own fetch handler for why Papyrus needs the identical fix applied directly, not inherited. */
|
|
47
|
+
const VEHICLE_INVOKE_IDLE_TIMEOUT_S = 3_600;
|
|
48
|
+
|
|
45
49
|
/** Start the supervised, long-running Papyrus service. */
|
|
46
50
|
export async function serveMain(): Promise<void> {
|
|
47
51
|
const stateDir = daemonStateDir();
|
|
@@ -81,7 +85,19 @@ export async function serveMain(): Promise<void> {
|
|
|
81
85
|
hostname: DAEMON_HOST,
|
|
82
86
|
port: 0,
|
|
83
87
|
fetch: (request, bunServer) => {
|
|
84
|
-
|
|
88
|
+
const pathname = new URL(request.url).pathname;
|
|
89
|
+
if (pathname === "/push") return pushChannel.upgrade(request, bunServer) ?? undefined;
|
|
90
|
+
// Bun.serve's own idleTimeout defaults to 10s and applies per-connection regardless of
|
|
91
|
+
// how long a given request is expected to take -- @danypops/vehicle-server's own daemon.ts
|
|
92
|
+
// (startBunListener) already fixed this for every Vehicle-backed daemon that goes through
|
|
93
|
+
// its shared startDaemon() substrate; Papyrus's own daemon.ts predates that substrate and
|
|
94
|
+
// has this separate, hand-rolled Bun.serve() call, so it needs the identical fix applied
|
|
95
|
+
// directly here. Real live incident (papyrus task d0eb81b7): tasks.run_gates/tasks.complete
|
|
96
|
+
// can legitimately take tens of seconds to actually run a caller's own gate command,
|
|
97
|
+
// sending zero response bytes the whole time -- just as exposed to Bun's 10s default as
|
|
98
|
+
// the streaming case, and neither gate.timeoutMs nor VehicleLimits.maxTimeoutMs ever gets a
|
|
99
|
+
// chance to apply if the raw TCP connection is already dead first.
|
|
100
|
+
if (pathname === "/vehicle/invoke") bunServer.timeout(request, VEHICLE_INVOKE_IDLE_TIMEOUT_S);
|
|
85
101
|
return app.fetch(request);
|
|
86
102
|
},
|
|
87
103
|
// A no-op fallback when pushChannel never calls server.upgrade() is safe: Bun only
|
package/src/domain/task-event.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/handlers/registry.ts
CHANGED
|
@@ -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:
|
|
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
|
|
666
|
+
if (result.error) return spawnErrorGateResult(gate, result.error);
|
|
645
667
|
const combined = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
|
|
646
|
-
|
|
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:
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/task/task-service.ts
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(() =>
|