@danypops/papyrus 0.46.2 → 0.47.1

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/src/index.ts CHANGED
@@ -35,4 +35,12 @@ export { taskContext } from "./task/task-context.ts";
35
35
  export { projectTaskExecution, type TaskExecutionPlan, type TaskExecutionState } from "./task/task-execution.ts";
36
36
  export { projectTaskGraph, type TaskGraphView } from "./task/task-graph-view.ts";
37
37
  export { fallbackLabel, projectTaskRelationships } from "./task/task-relationship-view.ts";
38
- export type { TaskCompletion, TaskGraph, TaskNode, TaskStatus } from "./task/task-service.ts";
38
+ export type {
39
+ TaskCompletion,
40
+ TaskGraph,
41
+ TaskLifecycleMutationResult,
42
+ TaskMutationReceiptView,
43
+ TaskNode,
44
+ TaskStatus,
45
+ } from "./task/task-service.ts";
46
+ export { TaskInvalidTransitionError, TaskMutationReceiptNotFoundError } from "./task/task-service.ts";
@@ -28,7 +28,7 @@ import type { OperationDefinition } from "../module-registry.ts";
28
28
  import type { SessionIdentity } from "../session-identity/session-identity-service.ts";
29
29
  import { taskContext } from "../task/task-context.ts";
30
30
  import { projectTaskExecution } from "../task/task-execution.ts";
31
- import type { TaskStatus, Tasks } from "../task/task-service.ts";
31
+ import type { TaskMutationRequestContext, TaskStatus, Tasks } from "../task/task-service.ts";
32
32
  import { type OperationInput, optionalBoolean, optionalNumber, optionalString, optionalStringArray, string } from "./operation-input.ts";
33
33
 
34
34
  const MODULE_ID = "tasks";
@@ -40,6 +40,11 @@ const eventContext = (input: OperationInput): TaskEventContext => ({
40
40
  reason: optionalString(input, "reason"),
41
41
  });
42
42
 
43
+ const mutationRequest = (input: OperationInput): TaskMutationRequestContext => ({
44
+ key: optionalString(input, "idempotency_key"),
45
+ caller: optionalString(input, "idempotency_caller"),
46
+ });
47
+
43
48
  const taskFilter = (input: OperationInput) => ({
44
49
  status: optionalString(input, "status"),
45
50
  text: optionalString(input, "text"),
@@ -83,6 +88,7 @@ export const TASKS_OPERATION_NAMES = [
83
88
  "tasks.start",
84
89
  "tasks.submit",
85
90
  "tasks.complete",
91
+ "tasks.mutation_status",
86
92
  "tasks.run_gates",
87
93
  "tasks.set_checklist",
88
94
  "tasks.set_gates",
@@ -195,20 +201,29 @@ export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore, sessionI
195
201
  }),
196
202
  define("tasks.pause", (input: OperationInput) => {
197
203
  guardFocusMutation(input);
198
- return tasks.pauseFocus(eventContext(input));
204
+ return tasks.pauseFocus(eventContext(input), mutationRequest(input));
199
205
  }),
200
206
  define("tasks.unpause", (input: OperationInput) => {
201
207
  guardFocusMutation(input);
202
- return tasks.unpauseFocus(eventContext(input));
208
+ return tasks.unpauseFocus(eventContext(input), mutationRequest(input));
203
209
  }),
204
210
  define("tasks.clear_focus", (input: OperationInput) => {
205
211
  guardFocusMutation(input);
206
212
  return tasks.clearFocus(eventContext(input));
207
213
  }),
208
214
  define("tasks.reap_stale_focus", () => ({ removed: tasks.reapStaleFocus() })),
209
- define("tasks.start", (input: OperationInput) => tasks.transition(string(input, "id"), "start", eventContext(input))),
210
- define("tasks.submit", (input: OperationInput) => tasks.transition(string(input, "id"), "submit", eventContext(input))),
211
- define("tasks.complete", (input: OperationInput) => tasks.completeAsync(string(input, "id"), eventContext(input))),
215
+ define("tasks.start", (input: OperationInput) =>
216
+ tasks.transition(string(input, "id"), "start", eventContext(input), mutationRequest(input)),
217
+ ),
218
+ define("tasks.submit", (input: OperationInput) =>
219
+ tasks.transition(string(input, "id"), "submit", eventContext(input), mutationRequest(input)),
220
+ ),
221
+ define("tasks.complete", (input: OperationInput) =>
222
+ tasks.completeAsync(string(input, "id"), eventContext(input), {}, mutationRequest(input)),
223
+ ),
224
+ define("tasks.mutation_status", (input: OperationInput) =>
225
+ tasks.mutationStatus(string(input, "idempotency_key"), optionalString(input, "idempotency_caller")),
226
+ ),
212
227
  define("tasks.run_gates", (input: OperationInput) => tasks.runGates(string(input, "id"), eventContext(input))),
213
228
  define("tasks.set_checklist", (input: OperationInput) => tasks.setChecklist(string(input, "id"), input.checklist as Checklist)),
214
229
  define("tasks.set_gates", (input: OperationInput) =>
@@ -222,10 +237,18 @@ export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore, sessionI
222
237
  optionalString(input, "verbosity") === "summary" ? "summary" : "full",
223
238
  ),
224
239
  ),
225
- define("tasks.reject", (input: OperationInput) => tasks.transition(string(input, "id"), "reject", eventContext(input))),
226
- define("tasks.retry", (input: OperationInput) => tasks.transition(string(input, "id"), "retry", eventContext(input))),
227
- define("tasks.cancel", (input: OperationInput) => tasks.transition(string(input, "id"), "cancel", eventContext(input))),
228
- define("tasks.reopen", (input: OperationInput) => tasks.transition(string(input, "id"), "reopen", eventContext(input))),
240
+ define("tasks.reject", (input: OperationInput) =>
241
+ tasks.transition(string(input, "id"), "reject", eventContext(input), mutationRequest(input)),
242
+ ),
243
+ define("tasks.retry", (input: OperationInput) =>
244
+ tasks.transition(string(input, "id"), "retry", eventContext(input), mutationRequest(input)),
245
+ ),
246
+ define("tasks.cancel", (input: OperationInput) =>
247
+ tasks.transition(string(input, "id"), "cancel", eventContext(input), mutationRequest(input)),
248
+ ),
249
+ define("tasks.reopen", (input: OperationInput) =>
250
+ tasks.transition(string(input, "id"), "reopen", eventContext(input), mutationRequest(input)),
251
+ ),
229
252
  define("tasks.cancel_subtree", (input: OperationInput) => tasks.cancelSubtree(string(input, "id"), eventContext(input))),
230
253
  define("tasks.depend", (input: OperationInput) =>
231
254
  tasks.depend(string(input, "id"), string(input, "dependency_id"), eventContext(input)),
package/src/service.ts CHANGED
@@ -43,6 +43,7 @@ import { SQLiteTaskCreateRequestStore } from "./stores/sqlite-task-create-reques
43
43
  import { SQLiteTaskEventStore } from "./stores/sqlite-task-event-store.ts";
44
44
  import { SQLiteTaskFocusStore } from "./stores/sqlite-task-focus-store.ts";
45
45
  import { SQLiteTaskLeaseStore } from "./stores/sqlite-task-lease-store.ts";
46
+ import { SQLiteTaskMutationRequestStore } from "./stores/sqlite-task-mutation-request-store.ts";
46
47
  import { SQLiteTaskScopeStore } from "./stores/sqlite-task-scope-store.ts";
47
48
  import type { TaskEventStore } from "./stores/task-event-store.ts";
48
49
  import type { TaskScopeStore } from "./stores/task-scope-store.ts";
@@ -368,6 +369,7 @@ function handlers(
368
369
  "tasks.start": forwardToModule("tasks.start"),
369
370
  "tasks.submit": forwardToModule("tasks.submit"),
370
371
  "tasks.complete": forwardToModule("tasks.complete"),
372
+ "tasks.mutation_status": forwardToModule("tasks.mutation_status"),
371
373
  "tasks.run_gates": forwardToModule("tasks.run_gates"),
372
374
  "tasks.set_checklist": forwardToModule("tasks.set_checklist"),
373
375
  "tasks.set_gates": forwardToModule("tasks.set_gates"),
@@ -454,7 +456,8 @@ export function createPapyrusService(path: string): PapyrusService {
454
456
  const scopes = new SQLiteTaskScopeStore(db);
455
457
  const leases = new SQLiteTaskLeaseStore(db);
456
458
  const createRequests = new SQLiteTaskCreateRequestStore(db);
457
- const tasks = new Tasks(artifacts, gates, focus, events, scopes, leases, createRequests);
459
+ const mutationRequests = new SQLiteTaskMutationRequestStore(db);
460
+ const tasks = new Tasks(artifacts, gates, focus, events, scopes, leases, createRequests, mutationRequests);
458
461
  const noteEvents = new SQLiteNoteEventStore(db);
459
462
  const notes = new Notes(artifacts, noteEvents);
460
463
  const projections = new SQLiteGraphProjectionStore(db);
@@ -0,0 +1,98 @@
1
+ import type { Db } from "../db.ts";
2
+ import { TaskMutationPendingError, type TaskMutationRequestRecord, type TaskMutationRequestStore } from "./task-mutation-request-store.ts";
3
+
4
+ interface TaskMutationRequestRow {
5
+ request_scope: string;
6
+ idempotency_key: string;
7
+ receipt_id: string;
8
+ task_id: string | null;
9
+ operation: string;
10
+ request_hash: string;
11
+ state: "pending" | "completed";
12
+ response_json: string | null;
13
+ created_at: string;
14
+ updated_at: string;
15
+ expires_at: string;
16
+ }
17
+
18
+ function mapRow(row: TaskMutationRequestRow): TaskMutationRequestRecord {
19
+ return {
20
+ scope: row.request_scope,
21
+ key: row.idempotency_key,
22
+ receiptId: row.receipt_id,
23
+ ...(row.task_id === null ? {} : { taskId: row.task_id }),
24
+ operation: row.operation,
25
+ requestHash: row.request_hash,
26
+ state: row.state,
27
+ ...(row.response_json === null ? {} : { responseJson: row.response_json }),
28
+ createdAt: row.created_at,
29
+ updatedAt: row.updated_at,
30
+ expiresAt: row.expires_at,
31
+ };
32
+ }
33
+
34
+ export class SQLiteTaskMutationRequestStore implements TaskMutationRequestStore {
35
+ constructor(private readonly db: Db) {}
36
+
37
+ get(scope: string, key: string, now: string): TaskMutationRequestRecord | undefined {
38
+ const row = this.db
39
+ .prepare("SELECT * FROM task_mutation_requests WHERE request_scope = ? AND idempotency_key = ? AND expires_at > ?")
40
+ .get(scope, key, now) as TaskMutationRequestRow | null;
41
+ return row ? mapRow(row) : undefined;
42
+ }
43
+
44
+ findPending(taskId: string, operation: string, now: string): TaskMutationRequestRecord | undefined {
45
+ const row = this.db
46
+ .prepare(
47
+ "SELECT * FROM task_mutation_requests WHERE task_id = ? AND operation = ? AND state = 'pending' AND expires_at > ? ORDER BY created_at LIMIT 1",
48
+ )
49
+ .get(taskId, operation, now) as TaskMutationRequestRow | null;
50
+ return row ? mapRow(row) : undefined;
51
+ }
52
+
53
+ put(record: TaskMutationRequestRecord): void {
54
+ try {
55
+ this.db
56
+ .prepare(`
57
+ INSERT INTO task_mutation_requests (
58
+ request_scope, idempotency_key, receipt_id, task_id, operation, request_hash,
59
+ state, response_json, created_at, updated_at, expires_at
60
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
61
+ `)
62
+ .run(
63
+ record.scope,
64
+ record.key,
65
+ record.receiptId,
66
+ record.taskId ?? null,
67
+ record.operation,
68
+ record.requestHash,
69
+ record.state,
70
+ record.responseJson ?? null,
71
+ record.createdAt,
72
+ record.updatedAt,
73
+ record.expiresAt,
74
+ );
75
+ } catch (error) {
76
+ const pending = record.taskId ? this.findPending(record.taskId, record.operation, record.createdAt) : undefined;
77
+ if (pending) {
78
+ throw new TaskMutationPendingError(`an earlier ${record.operation} outcome is still pending`, pending.receiptId, pending.operation);
79
+ }
80
+ throw error;
81
+ }
82
+ }
83
+
84
+ complete(scope: string, key: string, responseJson: string, updatedAt: string): void {
85
+ const result = this.db
86
+ .prepare(`
87
+ UPDATE task_mutation_requests
88
+ SET state = 'completed', response_json = ?, updated_at = ?
89
+ WHERE request_scope = ? AND idempotency_key = ?
90
+ `)
91
+ .run(responseJson, updatedAt, scope, key);
92
+ if (result.changes !== 1) throw new Error("task mutation receipt not found");
93
+ }
94
+
95
+ prune(now: string): number {
96
+ return this.db.prepare("DELETE FROM task_mutation_requests WHERE expires_at <= ?").run(now).changes;
97
+ }
98
+ }
@@ -0,0 +1,89 @@
1
+ export type TaskMutationRequestState = "pending" | "completed";
2
+
3
+ export interface TaskMutationRequestRecord {
4
+ scope: string;
5
+ key: string;
6
+ receiptId: string;
7
+ taskId?: string;
8
+ operation: string;
9
+ requestHash: string;
10
+ state: TaskMutationRequestState;
11
+ responseJson?: string;
12
+ createdAt: string;
13
+ updatedAt: string;
14
+ expiresAt: string;
15
+ }
16
+
17
+ export interface TaskMutationRequestStore {
18
+ get(scope: string, key: string, now: string): TaskMutationRequestRecord | undefined;
19
+ findPending(taskId: string, operation: string, now: string): TaskMutationRequestRecord | undefined;
20
+ put(record: TaskMutationRequestRecord): void;
21
+ complete(scope: string, key: string, responseJson: string, updatedAt: string): void;
22
+ prune(now: string): number;
23
+ }
24
+
25
+ export class TaskMutationIdempotencyConflictError extends Error {}
26
+
27
+ export class TaskMutationPendingError extends Error {
28
+ constructor(
29
+ message: string,
30
+ readonly receiptId: string,
31
+ readonly operation: string,
32
+ ) {
33
+ super(message);
34
+ }
35
+ }
36
+
37
+ export class InMemoryTaskMutationRequestStore implements TaskMutationRequestStore {
38
+ private readonly records = new Map<string, TaskMutationRequestRecord>();
39
+
40
+ private recordKey(scope: string, key: string): string {
41
+ return `${scope}\u0000${key}`;
42
+ }
43
+
44
+ get(scope: string, key: string, now: string): TaskMutationRequestRecord | undefined {
45
+ const record = this.records.get(this.recordKey(scope, key));
46
+ return !record || record.expiresAt <= now ? undefined : { ...record };
47
+ }
48
+
49
+ findPending(taskId: string, operation: string, now: string): TaskMutationRequestRecord | undefined {
50
+ for (const record of this.records.values()) {
51
+ if (record.taskId === taskId && record.operation === operation && record.state === "pending" && record.expiresAt > now) {
52
+ return { ...record };
53
+ }
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ put(record: TaskMutationRequestRecord): void {
59
+ if (record.state === "pending" && record.taskId) {
60
+ const existing = this.findPending(record.taskId, record.operation, record.createdAt);
61
+ if (existing) {
62
+ throw new TaskMutationPendingError(
63
+ `an earlier ${record.operation} outcome is still pending`,
64
+ existing.receiptId,
65
+ existing.operation,
66
+ );
67
+ }
68
+ }
69
+ this.records.set(this.recordKey(record.scope, record.key), { ...record });
70
+ }
71
+
72
+ complete(scope: string, key: string, responseJson: string, updatedAt: string): void {
73
+ const recordKey = this.recordKey(scope, key);
74
+ const record = this.records.get(recordKey);
75
+ if (!record) throw new Error("task mutation receipt not found");
76
+ this.records.set(recordKey, { ...record, state: "completed", responseJson, updatedAt });
77
+ }
78
+
79
+ prune(now: string): number {
80
+ let removed = 0;
81
+ for (const [key, record] of this.records) {
82
+ if (record.expiresAt <= now) {
83
+ this.records.delete(key);
84
+ removed += 1;
85
+ }
86
+ }
87
+ return removed;
88
+ }
89
+ }