@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/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/shared.ts +8 -0
- package/src/handlers/tasks.ts +164 -72
- 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/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
|
+
}
|