@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.
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/cli/task-command.ts +49 -8
- package/src/cli.ts +10 -8
- package/src/constants.ts +3 -1
- package/src/db.ts +44 -0
- package/src/domain-service-shared.ts +2 -3
- package/src/handlers/playbooks.ts +6 -2
- package/src/handlers/shared.ts +110 -23
- package/src/handlers/tasks.ts +134 -56
- package/src/id-migration.ts +2 -0
- package/src/index.ts +9 -1
- package/src/modules/tasks.ts +33 -10
- package/src/service.ts +4 -1
- package/src/stores/sqlite-task-mutation-request-store.ts +98 -0
- package/src/stores/task-mutation-request-store.ts +89 -0
- package/src/task/task-service.ts +402 -55
package/src/handlers/tasks.ts
CHANGED
|
@@ -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 {
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
605
|
+
(input) => input,
|
|
572
606
|
);
|
|
573
|
-
|
|
574
|
-
"
|
|
575
|
-
"
|
|
576
|
-
|
|
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
|
-
|
|
612
|
+
(input) => input,
|
|
580
613
|
);
|
|
614
|
+
focusOperation("clear_focus", "Clears the active Task Focus.", {}, [], (input) => input);
|
|
581
615
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
|
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.",
|
package/src/id-migration.ts
CHANGED
|
@@ -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 {
|
|
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";
|
package/src/modules/tasks.ts
CHANGED
|
@@ -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) =>
|
|
210
|
-
|
|
211
|
-
|
|
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) =>
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
define("tasks.
|
|
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
|
|
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
|
+
}
|