@danypops/papyrus 0.3.0 → 0.5.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.
@@ -2,9 +2,11 @@ import { TASK_EXECUTION_MAX_DEGREE, TASK_EXECUTION_MAX_EDGES, TASK_EXECUTION_MAX
2
2
  import type { Artifact } from "./domain/artifact.ts";
3
3
  import { checklistEntries, validateChecklist, type Checklist, type ProofReference } from "./domain/checklist.ts";
4
4
  import type { Gate, GateResult } from "./domain/gate.ts";
5
+ import type { AppendTaskEvent, TaskEventContext, TaskHistoryPage, TaskHistoryQuery, TaskLifecycleStatus } from "./domain/task-event.ts";
5
6
  import type { ArtifactStore } from "./ports/artifact-store.ts";
6
7
  import type { GateRunner } from "./ports/gate-runner.ts";
7
8
  import { InMemoryTaskFocusStore, type TaskFocusStore } from "./ports/task-focus-store.ts";
9
+ import { InMemoryTaskEventStore, type TaskEventStore } from "./ports/task-event-store.ts";
8
10
  import { assertDependencyEdgeAllowed } from "./task-execution.ts";
9
11
 
10
12
  export interface TaskFilter {
@@ -13,11 +15,13 @@ export interface TaskFilter {
13
15
  limit?: number;
14
16
  }
15
17
 
16
- export type TaskStatus = "todo" | "in-progress" | "review" | "rejected" | "done" | "canceled";
18
+ export type TaskStatus = TaskLifecycleStatus;
17
19
 
18
20
  export interface CreateTaskInput {
21
+ id?: string;
19
22
  title: string;
20
23
  body?: string;
24
+ subtype?: string;
21
25
  status?: TaskStatus;
22
26
  labels?: string[];
23
27
  extra?: Record<string, unknown>;
@@ -77,6 +81,7 @@ export class Tasks {
77
81
  private readonly artifacts: ArtifactStore,
78
82
  private readonly gates: GateRunner,
79
83
  private readonly focusStore: TaskFocusStore = new InMemoryTaskFocusStore(),
84
+ private readonly events: TaskEventStore = new InMemoryTaskEventStore(),
80
85
  ) {}
81
86
 
82
87
  private require(id: string): Artifact {
@@ -86,27 +91,32 @@ export class Tasks {
86
91
  return artifact;
87
92
  }
88
93
 
89
- create(input: CreateTaskInput): Artifact {
90
- if ((input.dependsOn?.length ?? 0) > TASK_EXECUTION_MAX_DEGREE) {
91
- throw new Error(`task cannot exceed ${TASK_EXECUTION_MAX_DEGREE} prerequisites`);
92
- }
93
- if (input.parentId) this.require(input.parentId);
94
- for (const dependency of input.dependsOn ?? []) this.require(dependency);
95
- const extra: Record<string, unknown> = { ...(input.extra ?? {}) };
96
- if (input.gates !== undefined) extra["gates"] = input.gates;
97
- if (input.checklist !== undefined) extra["checklist"] = validateChecklist(input.checklist);
98
- const task = this.artifacts.create({
99
- kind: "task",
100
- title: input.title,
101
- body: input.body,
102
- status: input.status,
103
- labels: input.labels,
104
- extra,
105
- templateId: input.templateId,
94
+ create(input: CreateTaskInput, context: TaskEventContext = {}): Artifact {
95
+ return this.events.atomic(() => {
96
+ if ((input.dependsOn?.length ?? 0) > TASK_EXECUTION_MAX_DEGREE) {
97
+ throw new Error(`task cannot exceed ${TASK_EXECUTION_MAX_DEGREE} prerequisites`);
98
+ }
99
+ if (input.parentId) this.require(input.parentId);
100
+ for (const dependency of input.dependsOn ?? []) this.require(dependency);
101
+ const extra: Record<string, unknown> = { ...(input.extra ?? {}) };
102
+ if (input.gates !== undefined) extra["gates"] = input.gates;
103
+ if (input.checklist !== undefined) extra["checklist"] = validateChecklist(input.checklist);
104
+ const task = this.artifacts.create({
105
+ id: input.id,
106
+ kind: "task",
107
+ title: input.title,
108
+ body: input.body,
109
+ subtype: input.subtype,
110
+ status: input.status,
111
+ labels: input.labels,
112
+ extra,
113
+ templateId: input.templateId,
114
+ });
115
+ if (input.parentId) this.contain(input.parentId, task.id);
116
+ for (const dependency of input.dependsOn ?? []) this.depend(task.id, dependency);
117
+ this.appendEvent({ taskId: task.id, type: "created", toStatus: task.status as TaskStatus }, context);
118
+ return this.show(task.id);
106
119
  });
107
- if (input.parentId) this.contain(input.parentId, task.id);
108
- for (const dependency of input.dependsOn ?? []) this.depend(task.id, dependency);
109
- return this.show(task.id);
110
120
  }
111
121
 
112
122
  list(filter: TaskFilter = {}): Artifact[] {
@@ -185,48 +195,55 @@ export class Tasks {
185
195
  return task;
186
196
  }
187
197
 
188
- transition(id: string, action: TaskTransition): Artifact {
189
- const task = this.require(id);
190
- const transition = TASK_TRANSITIONS[action];
191
- if (!transition.from.includes(task.status as TaskStatus)) throw new Error(`cannot ${action} task from ${task.status}`);
192
- if (action === "start") {
193
- const blocking = this.dependencyIds(id).filter((dependencyId) => this.require(dependencyId).status !== "done");
194
- if (blocking.length > 0) throw new Error(`task "${id}" is blocked by dependencies: ${blocking.join(", ")}`);
195
- this.focusStore.set(id);
196
- }
197
- const updated = this.artifacts.setStatus(id, transition.to)!;
198
- if (action === "start" || action === "retry") this.propagateProgressToAncestors(id);
199
- if (action === "retry") this.focusStore.set(id);
200
- if (action === "cancel") this.focusStore.clear(id);
201
- return updated;
198
+ transition(id: string, action: TaskTransition, context: TaskEventContext = {}): Artifact {
199
+ return this.events.atomic(() => {
200
+ const task = this.require(id);
201
+ const transition = TASK_TRANSITIONS[action];
202
+ if (!transition.from.includes(task.status as TaskStatus)) throw new Error(`cannot ${action} task from ${task.status}`);
203
+ if (action === "start") {
204
+ const blocking = this.dependencyIds(id).filter((dependencyId) => this.require(dependencyId).status !== "done");
205
+ if (blocking.length > 0) throw new Error(`task "${id}" is blocked by dependencies: ${blocking.join(", ")}`);
206
+ this.focusStore.set(id);
207
+ }
208
+ const updated = this.artifacts.setStatus(id, transition.to)!;
209
+ const eventType = { start: "started", submit: "submitted", reject: "review_rejected", retry: "retried", cancel: "canceled" }[action] as AppendTaskEvent["type"];
210
+ this.appendEvent({ taskId: id, type: eventType, fromStatus: task.status as TaskStatus, toStatus: transition.to }, context);
211
+ if (action === "start" || action === "retry") this.propagateProgressToAncestors(id, context);
212
+ if (action === "retry") this.focusStore.set(id);
213
+ if (action === "cancel") this.focusStore.clear(id);
214
+ return updated;
215
+ });
202
216
  }
203
217
 
204
- complete(id: string): TaskCompletion {
218
+ complete(id: string, context: TaskEventContext = {}): TaskCompletion {
205
219
  const task = this.requireReview(id);
220
+ const attemptId = crypto.randomUUID();
221
+ this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
206
222
  const checklist = this.reviewChecklist(task);
207
223
  const results = this.gates.run(id);
208
- if (results.some((gate) => !gate.passed) || checklist.some((item) => !item.accepted)) {
209
- const artifact = this.artifacts.setStatus(id, "rejected")!;
210
- return { artifact, gates: results, checklist, completed: false, focused: this.active(), blocked: [] };
211
- }
212
- return this.finish(id, results, checklist);
224
+ return this.resolveCompletion(id, attemptId, results, checklist, context);
213
225
  }
214
226
 
215
- async completeAsync(id: string): Promise<TaskCompletion> {
227
+ async completeAsync(id: string, context: TaskEventContext = {}): Promise<TaskCompletion> {
216
228
  const task = this.requireReview(id);
229
+ const attemptId = crypto.randomUUID();
230
+ this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
217
231
  const checklist = this.reviewChecklist(task);
218
232
  const results = await this.gates.runAsync(id);
219
- if (results.some((gate) => !gate.passed) || checklist.some((item) => !item.accepted)) {
220
- const artifact = this.artifacts.setStatus(id, "rejected")!;
221
- return { artifact, gates: results, checklist, completed: false, focused: this.active(), blocked: [] };
222
- }
223
- const current = this.requireReview(id);
224
- return this.finish(current.id, results, checklist);
233
+ this.requireReview(id);
234
+ return this.resolveCompletion(id, attemptId, results, checklist, context);
235
+ }
236
+
237
+ async runGates(id: string, context: TaskEventContext = {}): Promise<GateResult[]> {
238
+ this.require(id);
239
+ const results = await this.gates.runAsync(id);
240
+ this.events.atomic(() => this.appendEvent({ taskId: id, type: "gates_evaluated", evidence: { gates: results, result: results.every((gate) => gate.passed) ? "passed" : "failed" } }, context));
241
+ return results;
225
242
  }
226
243
 
227
- runGates(id: string): Promise<GateResult[]> {
244
+ history(id: string, query: TaskHistoryQuery = {}): TaskHistoryPage {
228
245
  this.require(id);
229
- return this.gates.runAsync(id);
246
+ return this.events.history(id, query);
230
247
  }
231
248
 
232
249
  setChecklist(id: string, checklist: Checklist): Artifact {
@@ -282,7 +299,7 @@ export class Tasks {
282
299
  .filter((parentId, index, ids) => ids.indexOf(parentId) === index);
283
300
  }
284
301
 
285
- private propagateProgressToAncestors(id: string): void {
302
+ private propagateProgressToAncestors(id: string, context: TaskEventContext): void {
286
303
  const pending = this.parentIds(id);
287
304
  const visited = new Set<string>();
288
305
  while (pending.length > 0) {
@@ -291,7 +308,14 @@ export class Tasks {
291
308
  if (visited.size >= TASK_EXECUTION_MAX_NODES) throw new Error("task ancestry exceeds execution node bound");
292
309
  visited.add(parentId);
293
310
  const parent = this.require(parentId);
294
- if (parent.status === "todo") this.artifacts.setStatus(parentId, "in-progress");
311
+ if (parent.status === "todo") {
312
+ this.artifacts.setStatus(parentId, "in-progress");
313
+ this.appendEvent({ taskId: parentId, type: "started", fromStatus: "todo", toStatus: "in-progress" }, {
314
+ ...context,
315
+ source: "task-ancestry",
316
+ reason: `nested task ${id} entered progress`,
317
+ });
318
+ }
295
319
  pending.push(...this.parentIds(parentId));
296
320
  }
297
321
  }
@@ -315,7 +339,32 @@ export class Tasks {
315
339
  return ids;
316
340
  }
317
341
 
318
- private finish(id: string, gates: GateResult[], checklist: ChecklistReview[]): TaskCompletion {
342
+ private resolveCompletion(
343
+ id: string,
344
+ attemptId: string,
345
+ gates: GateResult[],
346
+ checklist: ChecklistReview[],
347
+ context: TaskEventContext,
348
+ ): TaskCompletion {
349
+ const failed = gates.some((gate) => !gate.passed) || checklist.some((item) => !item.accepted);
350
+ if (failed) {
351
+ return this.events.atomic(() => {
352
+ const artifact = this.artifacts.setStatus(id, "rejected")!;
353
+ this.appendEvent({
354
+ taskId: id,
355
+ type: "review_rejected",
356
+ fromStatus: "review",
357
+ toStatus: "rejected",
358
+ attemptId,
359
+ evidence: { gates, checklist, result: "rejected" },
360
+ }, context);
361
+ return { artifact, gates, checklist, completed: false, focused: this.active(), blocked: [] };
362
+ });
363
+ }
364
+ return this.events.atomic(() => this.finish(id, attemptId, gates, checklist, context));
365
+ }
366
+
367
+ private finish(id: string, attemptId: string, gates: GateResult[], checklist: ChecklistReview[], context: TaskEventContext): TaskCompletion {
319
368
  const successorIds = this.relationships(id)
320
369
  .filter((edge) => edge.relation === "depends_on" && edge.to === id)
321
370
  .map((edge) => edge.from);
@@ -323,6 +372,14 @@ export class Tasks {
323
372
  throw new Error(`task "${id}" exceeds ${TASK_EXECUTION_MAX_DEGREE} successors`);
324
373
  }
325
374
  const artifact = this.artifacts.setStatus(id, "done")!;
375
+ this.appendEvent({
376
+ taskId: id,
377
+ type: "completed",
378
+ fromStatus: "review",
379
+ toStatus: "done",
380
+ attemptId,
381
+ evidence: { gates, checklist, result: "completed" },
382
+ }, context);
326
383
  this.focusStore.clear(id);
327
384
  const blocked: TaskBlockage[] = [];
328
385
  let focused: Artifact | null = null;
@@ -343,6 +400,16 @@ export class Tasks {
343
400
  return { artifact, gates, checklist, completed: true, focused, blocked };
344
401
  }
345
402
 
403
+ private appendEvent(event: Omit<AppendTaskEvent, "actor" | "source">, context: TaskEventContext): void {
404
+ this.events.append({
405
+ ...event,
406
+ actor: context.actor ?? "system",
407
+ source: context.source ?? "task-domain",
408
+ ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }),
409
+ ...(context.reason === undefined ? {} : { reason: context.reason }),
410
+ });
411
+ }
412
+
346
413
  private requireReview(id: string): Artifact {
347
414
  const task = this.require(id);
348
415
  if (task.status !== "review") throw new Error(`cannot complete task from ${task.status}`);