@danypops/papyrus 0.42.0 → 0.42.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 +2 -2
- package/src/adapters/sqlite-artifact-scope-store.ts +18 -9
- package/src/adapters/sqlite-artifact-store.ts +7 -5
- package/src/adapters/sqlite-discussion-round-store.ts +26 -17
- package/src/adapters/sqlite-gate-runner.ts +1 -1
- package/src/adapters/sqlite-graph-projection-store.ts +14 -10
- package/src/adapters/sqlite-log-store.ts +36 -17
- package/src/adapters/sqlite-note-event-store.ts +20 -16
- package/src/adapters/sqlite-session-identity-store.ts +13 -7
- package/src/adapters/sqlite-task-event-store.ts +29 -21
- package/src/adapters/sqlite-task-focus-store.ts +36 -10
- package/src/adapters/sqlite-task-lease-store.ts +12 -6
- package/src/adapters/sqlite-task-scope-store.ts +25 -14
- package/src/artifact-relationship-view.ts +3 -3
- package/src/artifact-subtree.ts +4 -2
- package/src/authority-registry.ts +2 -1
- package/src/cli.ts +785 -179
- package/src/client.ts +46 -20
- package/src/constants.ts +15 -4
- package/src/daemon-state.ts +4 -12
- package/src/daemon.ts +31 -9
- package/src/db.ts +119 -98
- package/src/discussion-service.ts +109 -44
- package/src/domain/artifact-event.ts +17 -4
- package/src/domain/artifact.ts +3 -1
- package/src/domain/blueprint-definition.ts +26 -31
- package/src/domain/checklist.ts +20 -17
- package/src/domain/discussion.ts +37 -18
- package/src/domain/gate.ts +7 -7
- package/src/domain/log-entry.ts +1 -1
- package/src/domain/note-event.ts +20 -7
- package/src/domain/task-event.ts +17 -7
- package/src/domain-services.ts +241 -110
- package/src/graph-projection-service.ts +34 -8
- package/src/id-migration.ts +17 -4
- package/src/index.ts +16 -11
- package/src/log-service.ts +6 -5
- package/src/log.ts +19 -0
- package/src/modules/discuss.ts +63 -28
- package/src/modules/docs.ts +74 -17
- package/src/modules/graph-projection.ts +20 -9
- package/src/modules/logs.ts +33 -21
- package/src/modules/notes.ts +66 -28
- package/src/modules/playbooks.ts +88 -29
- package/src/modules/rules.ts +57 -15
- package/src/modules/session-identity.ts +6 -2
- package/src/modules/tasks.ts +142 -67
- package/src/note-service.ts +11 -7
- package/src/ops.ts +131 -68
- package/src/playbook-definition.ts +56 -17
- package/src/playbook-execution.ts +13 -3
- package/src/ports/note-event-store.ts +6 -4
- package/src/ports/task-event-store.ts +9 -6
- package/src/ports/task-focus-store.ts +17 -4
- package/src/ports/task-lease-store.ts +9 -4
- package/src/ports/task-scope-store.ts +3 -1
- package/src/service.ts +143 -89
- package/src/session-identity-service.ts +10 -2
- package/src/task-context.ts +28 -16
- package/src/task-execution.ts +4 -12
- package/src/task-graph-view.ts +12 -12
- package/src/task-relationship-view.ts +1 -3
- package/src/task-service.ts +167 -72
- package/src/vehicle/artifact-trash-vehicle.ts +25 -13
- package/src/vehicle/artifact-vehicle-shared.ts +28 -7
- package/src/vehicle/docs-vehicle.ts +48 -16
- package/src/vehicle/notes-vehicle.ts +24 -6
- package/src/vehicle/papyrus-vehicle.ts +14 -3
- package/src/vehicle/playbooks-vehicle.ts +87 -18
- package/src/vehicle/rules-vehicle.ts +58 -21
- package/src/vehicle/tasks-vehicle.ts +366 -54
- package/src/version.ts +1 -1
- package/src/workflow-execution.ts +71 -52
package/src/task-service.ts
CHANGED
|
@@ -4,24 +4,38 @@ import {
|
|
|
4
4
|
TASK_EXECUTION_MAX_DEGREE,
|
|
5
5
|
TASK_EXECUTION_MAX_EDGES,
|
|
6
6
|
TASK_EXECUTION_MAX_NODES,
|
|
7
|
+
TASK_FOCUS_STALE_AFTER_MS,
|
|
7
8
|
TASK_LABEL_MAX_COUNT,
|
|
8
9
|
TASK_LABEL_MAX_LENGTH,
|
|
9
|
-
TASK_FOCUS_STALE_AFTER_MS,
|
|
10
10
|
TASK_SCOPE_MAX_TASKS,
|
|
11
11
|
TASK_TITLE_MAX_LENGTH,
|
|
12
12
|
} from "./constants.ts";
|
|
13
13
|
import type { Artifact } from "./domain/artifact.ts";
|
|
14
|
-
import {
|
|
14
|
+
import { type Checklist, checklistEntries, type ProofReference, validateChecklist } from "./domain/checklist.ts";
|
|
15
15
|
import { DISCUSSION_SUBTYPE, isDiscussionArtifact, readDiscussionExtra } from "./domain/discussion.ts";
|
|
16
|
-
import {
|
|
17
|
-
import type {
|
|
18
|
-
|
|
16
|
+
import { type Gate, type GateResult, validateGates } from "./domain/gate.ts";
|
|
17
|
+
import type {
|
|
18
|
+
AppendTaskEvent,
|
|
19
|
+
TaskEventContext,
|
|
20
|
+
TaskEventFeedPage,
|
|
21
|
+
TaskEventFeedQuery,
|
|
22
|
+
TaskHistoryPage,
|
|
23
|
+
TaskHistoryQuery,
|
|
24
|
+
TaskLifecycleStatus,
|
|
25
|
+
} from "./domain/task-event.ts";
|
|
26
|
+
import type { TaskLease } from "./domain/task-lease.ts";
|
|
27
|
+
import {
|
|
28
|
+
normalizeProjectRoot,
|
|
29
|
+
type TaskScopeSource,
|
|
30
|
+
type TaskViewMode,
|
|
31
|
+
type TaskViewSelection,
|
|
32
|
+
taskScopeLabel,
|
|
33
|
+
} from "./domain/task-scope.ts";
|
|
19
34
|
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
20
35
|
import type { GateRunner } from "./ports/gate-runner.ts";
|
|
21
|
-
import { InMemoryTaskFocusStore, type TaskFocusStatus, type TaskFocusStore } from "./ports/task-focus-store.ts";
|
|
22
36
|
import { InMemoryTaskEventStore, type TaskEventStore } from "./ports/task-event-store.ts";
|
|
37
|
+
import { InMemoryTaskFocusStore, type TaskFocusStatus, type TaskFocusStore } from "./ports/task-focus-store.ts";
|
|
23
38
|
import { InMemoryTaskLeaseStore, type TaskLeaseStore } from "./ports/task-lease-store.ts";
|
|
24
|
-
import type { TaskLease } from "./domain/task-lease.ts";
|
|
25
39
|
import { InMemoryTaskScopeStore, type TaskScopeStore } from "./ports/task-scope-store.ts";
|
|
26
40
|
import { assertDependencyEdgeAllowed } from "./task-execution.ts";
|
|
27
41
|
|
|
@@ -147,8 +161,8 @@ export class Tasks {
|
|
|
147
161
|
if (input.parentId) this.require(input.parentId);
|
|
148
162
|
for (const dependency of input.dependsOn ?? []) this.require(dependency);
|
|
149
163
|
const extra: Record<string, unknown> = { ...(input.extra ?? {}) };
|
|
150
|
-
if (input.gates !== undefined) extra
|
|
151
|
-
if (input.checklist !== undefined) extra
|
|
164
|
+
if (input.gates !== undefined) extra.gates = validateGates(input.gates);
|
|
165
|
+
if (input.checklist !== undefined) extra.checklist = validateChecklist(input.checklist);
|
|
152
166
|
const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
|
|
153
167
|
if (input.parentId && this.scopes.get(input.parentId)?.projectRoot !== projectRoot) {
|
|
154
168
|
throw new Error(`parent task "${input.parentId}" is outside project scope`);
|
|
@@ -179,18 +193,26 @@ export class Tasks {
|
|
|
179
193
|
if (task.status !== "done" && task.status !== "canceled") throw new Error(`cannot recover task creation from ${task.status}`);
|
|
180
194
|
const history = this.events.history(id, { direction: "asc", limit: 2 });
|
|
181
195
|
const created = history.events[0];
|
|
182
|
-
if (
|
|
196
|
+
if (
|
|
197
|
+
history.events.length !== 1 ||
|
|
198
|
+
history.nextCursor !== undefined ||
|
|
199
|
+
created?.type !== "created" ||
|
|
200
|
+
created.toStatus !== task.status
|
|
201
|
+
) {
|
|
183
202
|
throw new Error("task was not terminal at creation");
|
|
184
203
|
}
|
|
185
204
|
const recovered = this.artifacts.setStatus(id, "todo");
|
|
186
205
|
if (!recovered) throw new Error(`task "${id}" not found`);
|
|
187
|
-
this.appendEvent(
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
206
|
+
this.appendEvent(
|
|
207
|
+
{
|
|
208
|
+
taskId: id,
|
|
209
|
+
type: "creation_recovered",
|
|
210
|
+
fromStatus: task.status as TaskStatus,
|
|
211
|
+
toStatus: "todo",
|
|
212
|
+
evidence: { result: "terminal-at-creation" },
|
|
213
|
+
},
|
|
214
|
+
context,
|
|
215
|
+
);
|
|
194
216
|
return recovered;
|
|
195
217
|
});
|
|
196
218
|
}
|
|
@@ -204,11 +226,13 @@ export class Tasks {
|
|
|
204
226
|
return this.recoverCreation(id, context);
|
|
205
227
|
}
|
|
206
228
|
const fields = (["title", "body", "labels"] as const).filter((field) => input[field] !== undefined);
|
|
207
|
-
if (fields.length === 0)
|
|
229
|
+
if (fields.length === 0)
|
|
230
|
+
throw new Error("task update requires title, body, or labels; status todo is only valid for creation recovery");
|
|
208
231
|
if (input.title !== undefined && (input.title.trim().length === 0 || input.title.length > TASK_TITLE_MAX_LENGTH)) {
|
|
209
232
|
throw new Error(`title must be between 1 and ${TASK_TITLE_MAX_LENGTH} characters`);
|
|
210
233
|
}
|
|
211
|
-
if (input.body !== undefined && input.body.length > TASK_BODY_MAX_LENGTH)
|
|
234
|
+
if (input.body !== undefined && input.body.length > TASK_BODY_MAX_LENGTH)
|
|
235
|
+
throw new Error(`body cannot exceed ${TASK_BODY_MAX_LENGTH} characters`);
|
|
212
236
|
if (input.labels !== undefined) {
|
|
213
237
|
if (input.labels.length > TASK_LABEL_MAX_COUNT) throw new Error(`labels cannot exceed ${TASK_LABEL_MAX_COUNT} entries`);
|
|
214
238
|
if (input.labels.some((label) => label.length === 0 || label.length > TASK_LABEL_MAX_LENGTH)) {
|
|
@@ -231,7 +255,14 @@ export class Tasks {
|
|
|
231
255
|
throw new Error(`task list limit must be between 1 and ${TASK_SCOPE_MAX_TASKS + 1}`);
|
|
232
256
|
}
|
|
233
257
|
if (selection.mode === "all") {
|
|
234
|
-
return this.artifacts.query({
|
|
258
|
+
return this.artifacts.query({
|
|
259
|
+
kind: "task",
|
|
260
|
+
excludeSubtype: DISCUSSION_SUBTYPE,
|
|
261
|
+
status: filter.status,
|
|
262
|
+
text: filter.text,
|
|
263
|
+
labels: filter.labels,
|
|
264
|
+
limit,
|
|
265
|
+
});
|
|
235
266
|
}
|
|
236
267
|
const ids = this.scopes.taskIds(selection.projectRoot, TASK_SCOPE_MAX_TASKS + 1);
|
|
237
268
|
if (ids.length > TASK_SCOPE_MAX_TASKS) throw new Error(`task project scope exceeds ${TASK_SCOPE_MAX_TASKS} tasks`);
|
|
@@ -244,7 +275,9 @@ export class Tasks {
|
|
|
244
275
|
// leak a trashed task back into list results for the entire grace period. Scoped by ids
|
|
245
276
|
// rather than a bare kind query, so this stays bounded to exactly the already-bounded
|
|
246
277
|
// selectedIds set instead of scanning every task in the database.
|
|
247
|
-
const notTrashed = new Map(
|
|
278
|
+
const notTrashed = new Map(
|
|
279
|
+
this.artifacts.query({ kind: "task", excludeSubtype: DISCUSSION_SUBTYPE, ids: [...selectedIds] }).map((task) => [task.id, task]),
|
|
280
|
+
);
|
|
248
281
|
return [...selectedIds]
|
|
249
282
|
.map((id) => notTrashed.get(id))
|
|
250
283
|
.filter((task): task is Artifact => task !== undefined)
|
|
@@ -256,7 +289,8 @@ export class Tasks {
|
|
|
256
289
|
}
|
|
257
290
|
|
|
258
291
|
scopeSelection(projectRoot?: string, mode?: TaskViewMode, rootTaskId?: string): TaskViewSelection {
|
|
259
|
-
if (mode !== undefined && mode !== "project" && mode !== "graph" && mode !== "all")
|
|
292
|
+
if (mode !== undefined && mode !== "project" && mode !== "graph" && mode !== "all")
|
|
293
|
+
throw new Error("task scope must be project, graph, or all");
|
|
260
294
|
if (projectRoot === undefined) return { mode: "all", label: taskScopeLabel("all") };
|
|
261
295
|
const normalized = normalizeProjectRoot(projectRoot);
|
|
262
296
|
const persisted = this.scopes.view(normalized);
|
|
@@ -302,14 +336,19 @@ export class Tasks {
|
|
|
302
336
|
const focus = this.focusStore.get(filter.sessionId);
|
|
303
337
|
const focusedId = focus?.taskId;
|
|
304
338
|
const focusStatus = focus?.status;
|
|
305
|
-
const nodes = new Map(
|
|
306
|
-
task
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
339
|
+
const nodes = new Map(
|
|
340
|
+
tasks.map((task) => [
|
|
341
|
+
task.id,
|
|
342
|
+
{
|
|
343
|
+
task,
|
|
344
|
+
active: task.id === focusedId,
|
|
345
|
+
...(task.id === focusedId ? { focusStatus } : {}),
|
|
346
|
+
parentIds: [] as string[],
|
|
347
|
+
childIds: [] as string[],
|
|
348
|
+
dependencyIds: [] as string[],
|
|
349
|
+
},
|
|
350
|
+
]),
|
|
351
|
+
);
|
|
313
352
|
const relationships = this.artifacts.relationships({
|
|
314
353
|
kind: "task",
|
|
315
354
|
artifactIds: [...byId.keys()],
|
|
@@ -349,12 +388,17 @@ export class Tasks {
|
|
|
349
388
|
const focus = this.focusStore.get(filter?.sessionId);
|
|
350
389
|
if (!focus) return null;
|
|
351
390
|
const task = this.artifacts.get(focus.taskId);
|
|
352
|
-
if (
|
|
391
|
+
if (task?.kind !== "task" || task.status === "done" || task.status === "canceled") {
|
|
353
392
|
this.focusStore.clear(focus.taskId, filter?.sessionId);
|
|
354
393
|
return null;
|
|
355
394
|
}
|
|
356
395
|
if (filter?.projectRoot && !this.list(filter).some((candidate) => candidate.id === task.id)) return null;
|
|
357
|
-
return {
|
|
396
|
+
return {
|
|
397
|
+
artifact: task,
|
|
398
|
+
status: focus.status,
|
|
399
|
+
updatedAt: focus.updatedAt,
|
|
400
|
+
...(focus.pauseReason ? { pauseReason: focus.pauseReason } : {}),
|
|
401
|
+
};
|
|
358
402
|
}
|
|
359
403
|
|
|
360
404
|
active(filter?: TaskFilter): Artifact | null {
|
|
@@ -378,7 +422,12 @@ export class Tasks {
|
|
|
378
422
|
if (!focus) throw new Error("no focused task");
|
|
379
423
|
const state = this.focusStore.pause(focus.artifact.id, context.reason, context.sessionId);
|
|
380
424
|
this.appendEvent({ taskId: focus.artifact.id, type: "focus_paused" }, context);
|
|
381
|
-
return {
|
|
425
|
+
return {
|
|
426
|
+
artifact: focus.artifact,
|
|
427
|
+
status: state.status,
|
|
428
|
+
updatedAt: state.updatedAt,
|
|
429
|
+
...(state.pauseReason ? { pauseReason: state.pauseReason } : {}),
|
|
430
|
+
};
|
|
382
431
|
});
|
|
383
432
|
}
|
|
384
433
|
|
|
@@ -446,12 +495,19 @@ export class Tasks {
|
|
|
446
495
|
const transition = TASK_TRANSITIONS[action];
|
|
447
496
|
if (!transition.from.includes(task.status as TaskStatus)) throw new Error(`cannot ${action} task from ${task.status}`);
|
|
448
497
|
if (action === "start") {
|
|
449
|
-
const blocking = this.dependencyIds(id)
|
|
450
|
-
|
|
498
|
+
const blocking = this.dependencyIds(id)
|
|
499
|
+
.map((dependencyId) => this.require(dependencyId))
|
|
500
|
+
.filter((dependency) => dependency.status !== "done");
|
|
501
|
+
if (blocking.length > 0)
|
|
502
|
+
throw new Error(
|
|
503
|
+
`task "${task.title}" is blocked by dependencies: ${blocking.map((dependency) => `"${dependency.title}"`).join(", ")}`,
|
|
504
|
+
);
|
|
451
505
|
this.focusStore.set(id, context.sessionId);
|
|
452
506
|
}
|
|
453
507
|
const updated = this.artifacts.setStatus(id, transition.to)!;
|
|
454
|
-
const eventType = { start: "started", submit: "submitted", reject: "review_rejected", retry: "retried", cancel: "canceled" }[
|
|
508
|
+
const eventType = { start: "started", submit: "submitted", reject: "review_rejected", retry: "retried", cancel: "canceled" }[
|
|
509
|
+
action
|
|
510
|
+
] as AppendTaskEvent["type"];
|
|
455
511
|
this.appendEvent({ taskId: id, type: eventType, fromStatus: task.status as TaskStatus, toStatus: transition.to }, context);
|
|
456
512
|
if (action === "start" || action === "retry") this.propagateProgressToAncestors(id, context);
|
|
457
513
|
if (action === "retry") this.focusStore.set(id, context.sessionId);
|
|
@@ -480,8 +536,9 @@ export class Tasks {
|
|
|
480
536
|
visited.add(current);
|
|
481
537
|
if (visited.size > TASK_CANCEL_SUBTREE_MAX_NODES) throw new Error(`cancelSubtree exceeds ${TASK_CANCEL_SUBTREE_MAX_NODES} tasks`);
|
|
482
538
|
const task = this.artifacts.get(current);
|
|
483
|
-
if (
|
|
484
|
-
const childIds = this.artifacts
|
|
539
|
+
if (task?.kind !== "task") continue;
|
|
540
|
+
const childIds = this.artifacts
|
|
541
|
+
.relationships({ artifactIds: [current] })
|
|
485
542
|
.filter((edge) => edge.from === current && edge.relation === "contains")
|
|
486
543
|
.map((edge) => edge.to);
|
|
487
544
|
queue.push(...childIds);
|
|
@@ -499,7 +556,9 @@ export class Tasks {
|
|
|
499
556
|
const task = this.requireReview(id);
|
|
500
557
|
this.requireNotBlocked(task);
|
|
501
558
|
const attemptId = crypto.randomUUID();
|
|
502
|
-
this.events.atomic(() =>
|
|
559
|
+
this.events.atomic(() =>
|
|
560
|
+
this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context),
|
|
561
|
+
);
|
|
503
562
|
const checklist = this.reviewChecklist(task);
|
|
504
563
|
const results = this.gates.run(id, { cwd: this.scopes.get(id)?.projectRoot });
|
|
505
564
|
return this.resolveCompletion(id, attemptId, results, checklist, context, options);
|
|
@@ -509,7 +568,9 @@ export class Tasks {
|
|
|
509
568
|
const task = this.requireReview(id);
|
|
510
569
|
this.requireNotBlocked(task);
|
|
511
570
|
const attemptId = crypto.randomUUID();
|
|
512
|
-
this.events.atomic(() =>
|
|
571
|
+
this.events.atomic(() =>
|
|
572
|
+
this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context),
|
|
573
|
+
);
|
|
513
574
|
const checklist = this.reviewChecklist(task);
|
|
514
575
|
// project_root, never the daemon's own inherited process cwd -- see GateRunOptions.cwd's doc
|
|
515
576
|
// comment for the real incident this fixes (a command gate once tested the daemon's entire
|
|
@@ -522,7 +583,16 @@ export class Tasks {
|
|
|
522
583
|
async runGates(id: string, context: TaskEventContext = {}): Promise<GateResult[]> {
|
|
523
584
|
this.require(id);
|
|
524
585
|
const results = await this.gates.runAsync(id, { cwd: this.scopes.get(id)?.projectRoot });
|
|
525
|
-
this.events.atomic(() =>
|
|
586
|
+
this.events.atomic(() =>
|
|
587
|
+
this.appendEvent(
|
|
588
|
+
{
|
|
589
|
+
taskId: id,
|
|
590
|
+
type: "gates_evaluated",
|
|
591
|
+
evidence: { gates: results, result: results.every((gate) => gate.passed) ? "passed" : "failed" },
|
|
592
|
+
},
|
|
593
|
+
context,
|
|
594
|
+
),
|
|
595
|
+
);
|
|
526
596
|
return results;
|
|
527
597
|
}
|
|
528
598
|
|
|
@@ -593,7 +663,9 @@ export class Tasks {
|
|
|
593
663
|
return this.events.atomic(() => {
|
|
594
664
|
this.require(parentId);
|
|
595
665
|
this.require(childId);
|
|
596
|
-
const alreadyContained = this.relationships(parentId).some(
|
|
666
|
+
const alreadyContained = this.relationships(parentId).some(
|
|
667
|
+
(edge) => edge.relation === "contains" && edge.from === parentId && edge.to === childId,
|
|
668
|
+
);
|
|
597
669
|
this.artifacts.link({ from: parentId, relation: "contains", to: childId }, context);
|
|
598
670
|
this.artifacts.link({ from: childId, relation: "part_of", to: parentId }, context);
|
|
599
671
|
if (!alreadyContained) this.appendEvent({ taskId: parentId, type: "containment_added", reason: context.reason }, context);
|
|
@@ -621,7 +693,8 @@ export class Tasks {
|
|
|
621
693
|
artifactIds: projectTaskIds,
|
|
622
694
|
limit: TASK_EXECUTION_MAX_EDGES + 1,
|
|
623
695
|
});
|
|
624
|
-
if (relationships.length > TASK_EXECUTION_MAX_EDGES)
|
|
696
|
+
if (relationships.length > TASK_EXECUTION_MAX_EDGES)
|
|
697
|
+
throw new Error(`task project scope exceeds ${TASK_EXECUTION_MAX_EDGES} relationships`);
|
|
625
698
|
const children = new Map<string, string[]>();
|
|
626
699
|
for (const edge of relationships) {
|
|
627
700
|
const parentId = edge.relation === "contains" ? edge.from : edge.relation === "part_of" ? edge.to : undefined;
|
|
@@ -676,22 +749,25 @@ export class Tasks {
|
|
|
676
749
|
const parent = this.require(parentId);
|
|
677
750
|
if (parent.status === "todo") {
|
|
678
751
|
this.artifacts.setStatus(parentId, "in-progress");
|
|
679
|
-
this.appendEvent(
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
752
|
+
this.appendEvent(
|
|
753
|
+
{ taskId: parentId, type: "started", fromStatus: "todo", toStatus: "in-progress" },
|
|
754
|
+
{
|
|
755
|
+
...context,
|
|
756
|
+
source: "task-ancestry",
|
|
757
|
+
reason: `nested task ${id} entered progress`,
|
|
758
|
+
},
|
|
759
|
+
);
|
|
684
760
|
}
|
|
685
761
|
pending.push(...this.parentIds(parentId));
|
|
686
762
|
}
|
|
687
763
|
}
|
|
688
764
|
|
|
689
765
|
private reviewChecklist(task: Artifact): ChecklistReview[] {
|
|
690
|
-
return checklistEntries(task.extra
|
|
766
|
+
return checklistEntries(task.extra.checklist).map((entry) => ({
|
|
691
767
|
item: entry.item,
|
|
692
768
|
proof: entry.proof,
|
|
693
769
|
accepted: !entry.legacy && entry.proof.length > 0,
|
|
694
|
-
...(
|
|
770
|
+
...(entry.legacy || entry.proof.length === 0 ? { reason: "typed proof reference required" } : {}),
|
|
695
771
|
}));
|
|
696
772
|
}
|
|
697
773
|
|
|
@@ -717,21 +793,31 @@ export class Tasks {
|
|
|
717
793
|
if (failed) {
|
|
718
794
|
return this.events.atomic(() => {
|
|
719
795
|
const artifact = this.artifacts.setStatus(id, "rejected")!;
|
|
720
|
-
this.appendEvent(
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
796
|
+
this.appendEvent(
|
|
797
|
+
{
|
|
798
|
+
taskId: id,
|
|
799
|
+
type: "review_rejected",
|
|
800
|
+
fromStatus: "review",
|
|
801
|
+
toStatus: "rejected",
|
|
802
|
+
attemptId,
|
|
803
|
+
evidence: { gates, checklist, result: "rejected" },
|
|
804
|
+
},
|
|
805
|
+
context,
|
|
806
|
+
);
|
|
728
807
|
return { artifact, gates, checklist, completed: false, focused: this.active({ sessionId: context.sessionId }), blocked: [] };
|
|
729
808
|
});
|
|
730
809
|
}
|
|
731
810
|
return this.events.atomic(() => this.finish(id, attemptId, gates, checklist, context, options));
|
|
732
811
|
}
|
|
733
812
|
|
|
734
|
-
private finish(
|
|
813
|
+
private finish(
|
|
814
|
+
id: string,
|
|
815
|
+
attemptId: string,
|
|
816
|
+
gates: GateResult[],
|
|
817
|
+
checklist: ChecklistReview[],
|
|
818
|
+
context: TaskEventContext,
|
|
819
|
+
options: TaskCompletionOptions,
|
|
820
|
+
): TaskCompletion {
|
|
735
821
|
const successorIds = this.relationships(id)
|
|
736
822
|
.filter((edge) => edge.relation === "depends_on" && edge.to === id)
|
|
737
823
|
.map((edge) => edge.from);
|
|
@@ -739,22 +825,24 @@ export class Tasks {
|
|
|
739
825
|
throw new Error(`task "${id}" exceeds ${TASK_EXECUTION_MAX_DEGREE} successors`);
|
|
740
826
|
}
|
|
741
827
|
const artifact = this.artifacts.setStatus(id, "done")!;
|
|
742
|
-
this.appendEvent(
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
828
|
+
this.appendEvent(
|
|
829
|
+
{
|
|
830
|
+
taskId: id,
|
|
831
|
+
type: "completed",
|
|
832
|
+
fromStatus: "review",
|
|
833
|
+
toStatus: "done",
|
|
834
|
+
attemptId,
|
|
835
|
+
evidence: { gates, checklist, result: "completed" },
|
|
836
|
+
},
|
|
837
|
+
context,
|
|
838
|
+
);
|
|
750
839
|
this.focusStore.clearEverywhere(id);
|
|
751
840
|
const blocked: TaskBlockage[] = [];
|
|
752
841
|
let focused: Artifact | null = null;
|
|
753
842
|
for (const successorId of [...successorIds].sort()) {
|
|
754
843
|
const successor = this.require(successorId);
|
|
755
844
|
if (successor.status === "done" || successor.status === "canceled") continue;
|
|
756
|
-
const dependencyIds = this.dependencyIds(successorId)
|
|
757
|
-
.filter((dependencyId) => this.require(dependencyId).status !== "done");
|
|
845
|
+
const dependencyIds = this.dependencyIds(successorId).filter((dependencyId) => this.require(dependencyId).status !== "done");
|
|
758
846
|
if (dependencyIds.length > 0) {
|
|
759
847
|
blocked.push({ artifact: successor, dependencyIds });
|
|
760
848
|
continue;
|
|
@@ -794,19 +882,26 @@ export class Tasks {
|
|
|
794
882
|
* unrecognized shape.
|
|
795
883
|
*/
|
|
796
884
|
private blockingDiscussions(id: string): Artifact[] {
|
|
797
|
-
return this.artifacts
|
|
885
|
+
return this.artifacts
|
|
886
|
+
.relationships({ artifactIds: [id] })
|
|
798
887
|
.filter((edge) => edge.relation === "blocks" && edge.to === id)
|
|
799
888
|
.map((edge) => this.artifacts.get(edge.from))
|
|
800
889
|
.filter((source): source is Artifact => source !== null && isDiscussionArtifact(source))
|
|
801
890
|
.filter((discussion) => {
|
|
802
|
-
try {
|
|
891
|
+
try {
|
|
892
|
+
return readDiscussionExtra(discussion.extra).state === "active";
|
|
893
|
+
} catch {
|
|
894
|
+
return false;
|
|
895
|
+
}
|
|
803
896
|
});
|
|
804
897
|
}
|
|
805
898
|
|
|
806
899
|
private requireNotBlocked(task: Artifact): void {
|
|
807
900
|
const blockers = this.blockingDiscussions(task.id);
|
|
808
901
|
if (blockers.length > 0) {
|
|
809
|
-
throw new Error(
|
|
902
|
+
throw new Error(
|
|
903
|
+
`task "${task.title}" is blocked by ${blockers.length} active Discussion(s): ${blockers.map((discussion) => `"${discussion.title}"`).join(", ")}`,
|
|
904
|
+
);
|
|
810
905
|
}
|
|
811
906
|
}
|
|
812
907
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* artifact's kind. Registered once here, shared by every domain, instead of
|
|
4
4
|
* duplicated as rules.remove/docs.remove/etc.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { bindVehicleOperation, defineVehicleOperation } from "@danypops/vehicle-core";
|
|
7
7
|
import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
8
8
|
import { removeArtifactSubtree } from "../artifact-subtree.ts";
|
|
9
9
|
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
@@ -14,9 +14,9 @@ const OWNER = "artifact";
|
|
|
14
14
|
const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
|
|
15
15
|
|
|
16
16
|
function eventContext(input: Record<string, unknown>): { actor?: string; source?: string; sessionId?: string } {
|
|
17
|
-
const actor = input
|
|
18
|
-
const source = input
|
|
19
|
-
const sessionId = input
|
|
17
|
+
const actor = input.actor;
|
|
18
|
+
const source = input.source;
|
|
19
|
+
const sessionId = input.session_id ?? input.sessionId;
|
|
20
20
|
return {
|
|
21
21
|
actor: typeof actor === "string" ? actor : undefined,
|
|
22
22
|
source: typeof source === "string" ? source : undefined,
|
|
@@ -25,7 +25,7 @@ function eventContext(input: Record<string, unknown>): { actor?: string; source?
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
function requireId(input: Record<string, unknown>): string {
|
|
28
|
-
const id = input
|
|
28
|
+
const id = input.id;
|
|
29
29
|
if (typeof id !== "string" || id.length === 0) throw new Error("id is required");
|
|
30
30
|
return id;
|
|
31
31
|
}
|
|
@@ -50,7 +50,10 @@ export function registerArtifactTrashOperations(registry: VehicleRegistry, artif
|
|
|
50
50
|
idempotency: { mode: effect === "read" ? "safe" : "unsafe" },
|
|
51
51
|
limits: LIMITS,
|
|
52
52
|
});
|
|
53
|
-
registry.register(
|
|
53
|
+
registry.register(
|
|
54
|
+
OWNER,
|
|
55
|
+
bindVehicleOperation(operation, () => async (context) => execute(context.input)),
|
|
56
|
+
);
|
|
54
57
|
};
|
|
55
58
|
|
|
56
59
|
define(
|
|
@@ -59,11 +62,12 @@ export function registerArtifactTrashOperations(registry: VehicleRegistry, artif
|
|
|
59
62
|
"read",
|
|
60
63
|
{ id: stringProp, tree: { type: "boolean" } as unknown as { type: string }, depth: numberProp, max_nodes: numberProp },
|
|
61
64
|
["id"],
|
|
62
|
-
(input) =>
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
(input) =>
|
|
66
|
+
artifacts.get(requireId(input), {
|
|
67
|
+
tree: input.tree === true,
|
|
68
|
+
depth: typeof input.depth === "number" ? input.depth : undefined,
|
|
69
|
+
maxNodes: typeof input.max_nodes === "number" ? input.max_nodes : undefined,
|
|
70
|
+
}),
|
|
67
71
|
);
|
|
68
72
|
|
|
69
73
|
define(
|
|
@@ -72,7 +76,11 @@ export function registerArtifactTrashOperations(registry: VehicleRegistry, artif
|
|
|
72
76
|
"local-write",
|
|
73
77
|
{ id: stringProp, reason: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
|
|
74
78
|
["id"],
|
|
75
|
-
(input) =>
|
|
79
|
+
(input) =>
|
|
80
|
+
artifacts.trash(requireId(input), {
|
|
81
|
+
reason: typeof input.reason === "string" ? input.reason : undefined,
|
|
82
|
+
context: eventContext(input),
|
|
83
|
+
}),
|
|
76
84
|
);
|
|
77
85
|
|
|
78
86
|
define(
|
|
@@ -81,7 +89,11 @@ export function registerArtifactTrashOperations(registry: VehicleRegistry, artif
|
|
|
81
89
|
"local-write",
|
|
82
90
|
{ id: stringProp, reason: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
|
|
83
91
|
["id"],
|
|
84
|
-
(input) =>
|
|
92
|
+
(input) =>
|
|
93
|
+
removeArtifactSubtree(artifacts, requireId(input), {
|
|
94
|
+
reason: typeof input.reason === "string" ? input.reason : undefined,
|
|
95
|
+
context: eventContext(input),
|
|
96
|
+
}),
|
|
85
97
|
);
|
|
86
98
|
|
|
87
99
|
define(
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* VehicleRegistry projection (notes-vehicle.ts, rules-vehicle.ts, docs-vehicle.ts,
|
|
4
4
|
* artifact-trash-vehicle.ts).
|
|
5
5
|
*/
|
|
6
|
-
import { defineVehicleSchema, type
|
|
6
|
+
import { defineVehicleSchema, type VehicleContentBlock, type VehicleSchemaCodec } from "@danypops/vehicle-core";
|
|
7
7
|
import type { Artifact } from "../domain/artifact.ts";
|
|
8
8
|
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
9
9
|
import type { TaskExecutionPlan } from "../task-execution.ts";
|
|
@@ -14,7 +14,10 @@ import type { TaskExecutionPlan } from "../task-execution.ts";
|
|
|
14
14
|
* enforced at runtime -- so a declared `enum` has to be checked here for
|
|
15
15
|
* real, or it's a documentation gesture, not an honest contract.
|
|
16
16
|
*/
|
|
17
|
-
export function looseObjectSchema(
|
|
17
|
+
export function looseObjectSchema(
|
|
18
|
+
properties: Record<string, { type: string; enum?: readonly string[] }>,
|
|
19
|
+
required: readonly string[] = [],
|
|
20
|
+
): VehicleSchemaCodec<Record<string, unknown>> {
|
|
18
21
|
return defineVehicleSchema<Record<string, unknown>>({
|
|
19
22
|
jsonSchema: { type: "object", properties, required: [...required], additionalProperties: false },
|
|
20
23
|
safeParse(value) {
|
|
@@ -61,7 +64,9 @@ export function matchArtifactByName(candidates: readonly Artifact[], name: strin
|
|
|
61
64
|
const matches = candidates.filter((artifact) => artifact.title.trim().toLowerCase() === needle);
|
|
62
65
|
if (matches.length === 0) throw new Error(`no artifact named "${name}" found in this scope`);
|
|
63
66
|
if (matches.length > 1) {
|
|
64
|
-
throw new Error(
|
|
67
|
+
throw new Error(
|
|
68
|
+
`${matches.length} artifacts are named "${name}": ${matches.map((a) => `${a.title} (${a.id})`).join(", ")} -- use id to disambiguate`,
|
|
69
|
+
);
|
|
65
70
|
}
|
|
66
71
|
return matches[0]!.id;
|
|
67
72
|
}
|
|
@@ -72,7 +77,11 @@ export function matchArtifactByName(candidates: readonly Artifact[], name: strin
|
|
|
72
77
|
* flow only -- the caller supplies its own scoped/widened list calls, since scoping
|
|
73
78
|
* differs per domain. Omit `fetchWidened` when there is no wider scope to retry.
|
|
74
79
|
*/
|
|
75
|
-
export function resolveArtifactIdWidened(
|
|
80
|
+
export function resolveArtifactIdWidened(
|
|
81
|
+
name: string,
|
|
82
|
+
fetchCandidates: () => readonly Artifact[],
|
|
83
|
+
fetchWidened?: () => readonly Artifact[],
|
|
84
|
+
): string {
|
|
76
85
|
try {
|
|
77
86
|
return matchArtifactByName(fetchCandidates(), name);
|
|
78
87
|
} catch (error) {
|
|
@@ -87,7 +96,12 @@ export function labelsById(artifacts: ArtifactStore, ids: readonly string[]): Ma
|
|
|
87
96
|
const resolved = uniqueIds.map((id) => artifacts.get(id)).filter((artifact): artifact is Artifact => artifact !== null);
|
|
88
97
|
const titleCounts = new Map<string, number>();
|
|
89
98
|
for (const artifact of resolved) titleCounts.set(artifact.title, (titleCounts.get(artifact.title) ?? 0) + 1);
|
|
90
|
-
return new Map(
|
|
99
|
+
return new Map(
|
|
100
|
+
resolved.map((artifact) => [
|
|
101
|
+
artifact.id,
|
|
102
|
+
(titleCounts.get(artifact.title) ?? 0) > 1 ? `${artifact.title} (${artifact.id})` : artifact.title,
|
|
103
|
+
]),
|
|
104
|
+
);
|
|
91
105
|
}
|
|
92
106
|
|
|
93
107
|
export interface WorkflowRunNarrativeInput {
|
|
@@ -103,14 +117,21 @@ export interface WorkflowRunNarrativeInput {
|
|
|
103
117
|
* execution DAG -- the same shape pi-papyrus's own hand-rolled playbooks tool built
|
|
104
118
|
* client-side, now built once here where the run result is actually produced.
|
|
105
119
|
*/
|
|
106
|
-
export function buildWorkflowRunContent(
|
|
120
|
+
export function buildWorkflowRunContent(
|
|
121
|
+
artifacts: ArtifactStore,
|
|
122
|
+
headline: string,
|
|
123
|
+
input: WorkflowRunNarrativeInput,
|
|
124
|
+
extraLines: readonly string[] = [],
|
|
125
|
+
): VehicleContentBlock {
|
|
107
126
|
const nodeById = new Map(input.execution.nodes.map((node) => [node.id, node]));
|
|
108
127
|
const rootLabels = input.rootTaskIds.map((id) => nodeById.get(id)?.title ?? "unknown task");
|
|
109
128
|
const createdLabels = labelsById(artifacts, [...input.created.docs, ...input.created.rules]);
|
|
110
129
|
const titleCounts = new Map<string, number>();
|
|
111
130
|
for (const node of input.execution.nodes) titleCounts.set(node.title, (titleCounts.get(node.title) ?? 0) + 1);
|
|
112
131
|
const executionLines = input.execution.nodes
|
|
113
|
-
.map((node) =>
|
|
132
|
+
.map((node) =>
|
|
133
|
+
(titleCounts.get(node.title) ?? 0) > 1 ? ` [${node.state}] ${node.title} (${node.id})` : ` [${node.state}] ${node.title}`,
|
|
134
|
+
)
|
|
114
135
|
.join("\n");
|
|
115
136
|
const text = [
|
|
116
137
|
headline,
|