@danypops/papyrus 0.4.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.
- package/README.md +5 -4
- package/extension/src/domain-tools.ts +16 -5
- package/extension/src/task-detail-format.ts +24 -1
- package/extension/src/task-detail-view.ts +6 -3
- package/extension/src/tasks.ts +7 -5
- package/package.json +1 -1
- package/src/adapters/sqlite-task-event-store.ts +92 -0
- package/src/cli.ts +17 -7
- package/src/constants.ts +7 -1
- package/src/db.ts +77 -27
- package/src/domain/task-event.ts +102 -0
- package/src/ports/task-event-store.ts +43 -0
- package/src/service.ts +58 -15
- package/src/skill-execution.ts +26 -10
- package/src/task-service.ts +120 -53
package/README.md
CHANGED
|
@@ -101,7 +101,7 @@ Internally, application services depend on the `ArtifactStore` and `GateRunner`
|
|
|
101
101
|
|
|
102
102
|
## Interactive frontends
|
|
103
103
|
|
|
104
|
-
- `/tasks` — task lifecycle, gates, dependencies, and nested metadata
|
|
104
|
+
- `/tasks` — task lifecycle, append-only history, gates, dependencies, and nested metadata
|
|
105
105
|
- `/docs` — searchable documents, lifecycle, details, and graph links
|
|
106
106
|
- `/rules` — severity/condition rows, exact injection preview, enable/disable, and task gating
|
|
107
107
|
- `/skills` — trigger/tools rows, invocation into the editor, and artifact templates
|
|
@@ -122,7 +122,7 @@ Run `/tasks` for the interactive task panel:
|
|
|
122
122
|
- successors are never auto-completed; fan-in, fan-out, diamonds, and disconnected DAGs remain explicit
|
|
123
123
|
- inspect deterministic execution layers, readiness, a box-drawn nested hierarchy, composition, dependencies, evidence-bearing checklists, and verification gates
|
|
124
124
|
- lifecycle colors are semantic and redundant with text/glyphs: To-Do grey, in-progress yellow, review blue, rejected orange, done green, and canceled red; `▶` marks active focus
|
|
125
|
-
- Show details keeps Checklist and Validation gates separate from incidental Metadata, then renders relationships as a Unicode box-drawing graph footer; `↑/↓` scrolls and `←/→` pans wide graphs
|
|
125
|
+
- Show details keeps Checklist and Validation gates separate from incidental Metadata, renders bounded post-migration lifecycle history with actor/source/reason and gate evidence, then renders relationships as a Unicode box-drawing graph footer; `↑/↓` scrolls and `←/→` pans wide graphs
|
|
126
126
|
- the compact persistent widget shows bounded open work in containment order and always retains the active focus
|
|
127
127
|
|
|
128
128
|
Authenticated CLI parity covers the changed lifecycle and focus operations:
|
|
@@ -130,6 +130,7 @@ Authenticated CLI parity covers the changed lifecycle and focus operations:
|
|
|
130
130
|
```bash
|
|
131
131
|
papyrus tasks graph --json
|
|
132
132
|
papyrus tasks active --json
|
|
133
|
+
papyrus tasks history <id> --json
|
|
133
134
|
papyrus tasks focus <id> --json
|
|
134
135
|
papyrus tasks start <id> --json
|
|
135
136
|
papyrus tasks submit <id> --json
|
|
@@ -171,10 +172,10 @@ packed install npm:@danypops/papyrus
|
|
|
171
172
|
~/.pi/agent/npm/node_modules/.bin/papyrus service install
|
|
172
173
|
```
|
|
173
174
|
|
|
174
|
-
Existing databases are never migrated on daemon boot. After upgrading
|
|
175
|
+
Existing databases are never migrated on daemon boot. After upgrading to append-only task history, run the authenticated CLI migration explicitly. A v1 database receives the lifecycle prerequisite and history schema in one transaction; existing tasks receive no fabricated events:
|
|
175
176
|
|
|
176
177
|
```bash
|
|
177
|
-
~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-
|
|
178
|
+
~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-history
|
|
178
179
|
```
|
|
179
180
|
|
|
180
181
|
Until that command succeeds, health reports `migrationRequired` and normal domain operations are rejected with actionable guidance. Migration is not exposed as a Pi tool or MCP action. New empty databases bootstrap directly at the current schema.
|
|
@@ -4,6 +4,7 @@ import type { Artifact } from "../../src/domain/artifact.ts";
|
|
|
4
4
|
import { PROOF_TYPES } from "../../src/domain/checklist.ts";
|
|
5
5
|
import type { GateResult } from "../../src/domain/gate.ts";
|
|
6
6
|
import type { TaskExecutionPlan } from "../../src/task-execution.ts";
|
|
7
|
+
import type { TaskHistoryPage } from "../../src/domain/task-event.ts";
|
|
7
8
|
import type { TaskCompletion, TaskGraph } from "../../src/task-service.ts";
|
|
8
9
|
import type { SkillWorkflowRunResult } from "../../src/skill-execution.ts";
|
|
9
10
|
import { callService } from "./service-client.ts";
|
|
@@ -30,7 +31,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
30
31
|
pi.registerTool({
|
|
31
32
|
name: "tasks",
|
|
32
33
|
label: "Tasks",
|
|
33
|
-
description: "Task domain tool. ACTIONS: create, list, show, graph, plan, active, focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, contain. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. Prefer this over low-level papyrus_* tools for task work.",
|
|
34
|
+
description: "Task domain tool. ACTIONS: create, list, show, history, graph, plan, active, focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, contain. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. Prefer this over low-level papyrus_* tools for task work.",
|
|
34
35
|
parameters: Type.Object({
|
|
35
36
|
action: Type.String(),
|
|
36
37
|
id: Type.Optional(Type.String()),
|
|
@@ -39,6 +40,10 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
39
40
|
status: Type.Optional(Type.String()),
|
|
40
41
|
text: Type.Optional(Type.String()),
|
|
41
42
|
limit: Type.Optional(Type.Number()),
|
|
43
|
+
cursor: Type.Optional(Type.Number()),
|
|
44
|
+
direction: Type.Optional(Type.Union([Type.Literal("asc"), Type.Literal("desc")])),
|
|
45
|
+
reason: Type.Optional(Type.String()),
|
|
46
|
+
session_id: Type.Optional(Type.String()),
|
|
42
47
|
labels: Type.Optional(Type.Array(Type.String())),
|
|
43
48
|
extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
44
49
|
gates: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Unknown()))),
|
|
@@ -52,8 +57,9 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
52
57
|
async execute(_id, params) {
|
|
53
58
|
try {
|
|
54
59
|
const action = params.action;
|
|
60
|
+
const request = { ...params, actor: "agent", source: "pi-tool" };
|
|
55
61
|
if (action === "create") {
|
|
56
|
-
const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create",
|
|
62
|
+
const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
|
|
57
63
|
return text(`Created task ${artifactLine(artifact)}`, { artifact });
|
|
58
64
|
}
|
|
59
65
|
if (action === "list") {
|
|
@@ -64,6 +70,11 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
64
70
|
const artifact = await callService<Record<string, unknown>, Artifact>("tasks.show", params);
|
|
65
71
|
return text(`${artifactLine(artifact)}\n\n${artifact.body}`, { artifact });
|
|
66
72
|
}
|
|
73
|
+
if (action === "history") {
|
|
74
|
+
const page = await callService<Record<string, unknown>, TaskHistoryPage>("tasks.history", request);
|
|
75
|
+
const lines = page.events.map((event) => `${event.occurredAt} ${event.type} ${event.fromStatus ?? "∅"} → ${event.toStatus ?? "∅"} · ${event.actor}/${event.source}${event.reason ? ` · ${event.reason}` : ""}`);
|
|
76
|
+
return text(lines.join("\n") || "No recorded history for this task.", { page });
|
|
77
|
+
}
|
|
67
78
|
if (action === "active") {
|
|
68
79
|
const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active", params);
|
|
69
80
|
return text(artifact ? `Active: ${artifactLine(artifact)}` : "No active task.", { artifact });
|
|
@@ -92,7 +103,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
92
103
|
return text(`Updated checklist: ${artifactLine(artifact)}`, { artifact });
|
|
93
104
|
}
|
|
94
105
|
if (action === "complete") {
|
|
95
|
-
const result = await callService<Record<string, unknown>, TaskCompletion>("tasks.complete",
|
|
106
|
+
const result = await callService<Record<string, unknown>, TaskCompletion>("tasks.complete", request);
|
|
96
107
|
const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n");
|
|
97
108
|
const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}${item.reason ? ` — ${item.reason}` : ""}`).join("\n");
|
|
98
109
|
const focused = result.focused ? `\nActive: ${artifactLine(result.focused)}` : "";
|
|
@@ -102,7 +113,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
102
113
|
return text(`${result.completed ? "Completed" : "Rejected"}: ${artifactLine(result.artifact)}${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`, { ...result });
|
|
103
114
|
}
|
|
104
115
|
if (action === "run_gates") {
|
|
105
|
-
const gates = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates",
|
|
116
|
+
const gates = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", request);
|
|
106
117
|
return text(gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n") || "No gates configured.", { gates });
|
|
107
118
|
}
|
|
108
119
|
const operations = {
|
|
@@ -117,7 +128,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
117
128
|
} as const;
|
|
118
129
|
const operation = operations[action as keyof typeof operations];
|
|
119
130
|
if (!operation) return text(`Unknown tasks action: ${action}`);
|
|
120
|
-
const artifact = await callService<Record<string, unknown>, Artifact>(operation,
|
|
131
|
+
const artifact = await callService<Record<string, unknown>, Artifact>(operation, request);
|
|
121
132
|
return text(artifactLine(artifact), { artifact });
|
|
122
133
|
} catch (error) {
|
|
123
134
|
return text(`tasks failed: ${error instanceof Error ? error.message : error}`);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
2
|
+
import type { TaskEvent } from "../../src/domain/task-event.ts";
|
|
2
3
|
import { checklistEntries, type ProofReference } from "../../src/domain/checklist.ts";
|
|
3
4
|
import { formatMetadata } from "./artifact-format.ts";
|
|
4
5
|
|
|
@@ -48,7 +49,28 @@ function gateLines(value: unknown): string[] {
|
|
|
48
49
|
return lines;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
|
|
52
|
+
function historyLines(history: TaskEvent[]): string[] {
|
|
53
|
+
if (history.length === 0) return ["History:", " (no post-migration events recorded)"];
|
|
54
|
+
const lines = ["History:"];
|
|
55
|
+
for (const event of history) {
|
|
56
|
+
const transition = event.fromStatus || event.toStatus ? ` · ${event.fromStatus ?? "∅"} → ${event.toStatus ?? "∅"}` : "";
|
|
57
|
+
const reason = event.reason ? ` · ${event.reason}` : "";
|
|
58
|
+
lines.push(` ${event.occurredAt} · ${event.type}${transition} · ${event.actor}/${event.source}${reason}`);
|
|
59
|
+
if (event.evidence?.result) lines.push(` result: ${event.evidence.result}`);
|
|
60
|
+
if (Array.isArray(event.evidence?.gates)) {
|
|
61
|
+
for (const value of event.evidence.gates) {
|
|
62
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
63
|
+
const result = value as Record<string, unknown>;
|
|
64
|
+
const gate = typeof result["gate"] === "object" && result["gate"] !== null ? result["gate"] as Record<string, unknown> : {};
|
|
65
|
+
const passed = result["passed"] === true;
|
|
66
|
+
lines.push(` ${passed ? "✓" : "✗"} ${String(gate["type"] ?? "gate")} · ${String(gate["target"] ?? "unknown")}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function taskDetailsText(task: Artifact, relationshipGraphLines: string[] = [], history: TaskEvent[] = []): string {
|
|
52
74
|
let output = `${TASK_STATUS_GLYPHS[task.status] ?? "?"} ${task.title}\n${task.id} [task|${task.status}]`;
|
|
53
75
|
if (task.labels.length > 0) output += `\nLabels: ${task.labels.join(", ")}`;
|
|
54
76
|
output += `\n\n${task.body || "(no body)"}`;
|
|
@@ -60,6 +82,7 @@ export function taskDetailsText(task: Artifact, relationshipGraphLines: string[]
|
|
|
60
82
|
if (Object.keys(metadata).length > 0) {
|
|
61
83
|
output += `\n\nMetadata:\n${formatMetadata(metadata).map((line) => ` ${line}`).join("\n")}`;
|
|
62
84
|
}
|
|
85
|
+
output += `\n\n${historyLines(history).join("\n")}`;
|
|
63
86
|
if (task.edges?.length) {
|
|
64
87
|
const graph = relationshipGraphLines.length > 0 ? relationshipGraphLines.join("\n") : " (graph unavailable)";
|
|
65
88
|
output += `\n\nRelationships:\n Dependencies point prerequisite → dependent.\n${graph}`;
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
TASK_DETAIL_RESERVED_ROWS,
|
|
8
8
|
} from "../../src/constants.ts";
|
|
9
9
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
10
|
+
import type { TaskEvent } from "../../src/domain/task-event.ts";
|
|
10
11
|
import type { GraphRenderer } from "../../src/ports/graph-renderer.ts";
|
|
11
12
|
import { projectTaskRelationships } from "../../src/task-relationship-view.ts";
|
|
12
13
|
import type { TaskGraph } from "../../src/task-service.ts";
|
|
@@ -31,13 +32,14 @@ class TaskDetailViewport {
|
|
|
31
32
|
private readonly theme: Theme,
|
|
32
33
|
task: Artifact,
|
|
33
34
|
private readonly graphLines: string[],
|
|
35
|
+
history: TaskEvent[],
|
|
34
36
|
private readonly close: () => void,
|
|
35
37
|
) {
|
|
36
38
|
this.visibleLines = Math.max(
|
|
37
39
|
TASK_DETAIL_MIN_VISIBLE_LINES,
|
|
38
40
|
Math.min(TASK_DETAIL_MAX_VISIBLE_LINES, tui.terminal.rows - TASK_DETAIL_RESERVED_ROWS),
|
|
39
41
|
);
|
|
40
|
-
this.narrative = taskDetailsText({ ...task, edges: undefined });
|
|
42
|
+
this.narrative = taskDetailsText({ ...task, edges: undefined }, [], history);
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
invalidate(): void { this.renderedWidth = 0; }
|
|
@@ -99,13 +101,14 @@ export async function showTaskDetails(
|
|
|
99
101
|
task: Artifact,
|
|
100
102
|
graph?: TaskGraph,
|
|
101
103
|
renderer: GraphRenderer = new BeautifulMermaidRenderer(),
|
|
104
|
+
history: TaskEvent[] = [],
|
|
102
105
|
): Promise<void> {
|
|
103
106
|
const relationshipGraph = renderer.render(projectTaskRelationships(task, graph)).lines;
|
|
104
|
-
const content = taskDetailsText(task, relationshipGraph);
|
|
107
|
+
const content = taskDetailsText(task, relationshipGraph, history);
|
|
105
108
|
if (ctx.mode !== "tui") {
|
|
106
109
|
ctx.ui.notify(content, "info");
|
|
107
110
|
return;
|
|
108
111
|
}
|
|
109
112
|
await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
|
|
110
|
-
new TaskDetailViewport(tui, theme, task, relationshipGraph, done));
|
|
113
|
+
new TaskDetailViewport(tui, theme, task, relationshipGraph, history, done));
|
|
111
114
|
}
|
package/extension/src/tasks.ts
CHANGED
|
@@ -14,6 +14,7 @@ export { taskDetailsText } from "./task-detail-format.ts";
|
|
|
14
14
|
export { showTaskDetails } from "./task-detail-view.ts";
|
|
15
15
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
16
16
|
import type { GateResult } from "../../src/domain/gate.ts";
|
|
17
|
+
import type { TaskHistoryPage } from "../../src/domain/task-event.ts";
|
|
17
18
|
import { projectTaskExecution } from "../../src/task-execution.ts";
|
|
18
19
|
import type { TaskCompletion, TaskGraph, TaskStatus } from "../../src/task-service.ts";
|
|
19
20
|
import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
|
|
@@ -70,7 +71,7 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
70
71
|
if (create === "Create a task") {
|
|
71
72
|
const title = await ctx.ui.input("Task title:", "");
|
|
72
73
|
if (title) {
|
|
73
|
-
await callService("tasks.create", { title });
|
|
74
|
+
await callService("tasks.create", { title, actor: "user", source: "tasks-tui" });
|
|
74
75
|
graph = await loadTaskGraph();
|
|
75
76
|
}
|
|
76
77
|
}
|
|
@@ -97,7 +98,8 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
97
98
|
if (choice === "Show details") {
|
|
98
99
|
const art = await callService<Record<string, unknown>, Artifact | null>("tasks.show", { id: action.row.id });
|
|
99
100
|
if (!art) { ctx.ui.notify("Not found", "error"); continue; }
|
|
100
|
-
await
|
|
101
|
+
const history = await callService<Record<string, unknown>, TaskHistoryPage>("tasks.history", { id: art.id, direction: "desc" });
|
|
102
|
+
await showTaskDetails(ctx, art, graph, undefined, [...history.events].reverse());
|
|
101
103
|
} else if (choice === "Make active") {
|
|
102
104
|
try {
|
|
103
105
|
await callService<Record<string, unknown>, Artifact>("tasks.focus", { id: action.row.id });
|
|
@@ -107,7 +109,7 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
107
109
|
}
|
|
108
110
|
} else if (choice === "Run gates") {
|
|
109
111
|
try {
|
|
110
|
-
const results = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", { id: action.row.id });
|
|
112
|
+
const results = await callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", { id: action.row.id, actor: "user", source: "tasks-tui" });
|
|
111
113
|
ctx.ui.notify(`Gates:\n${results.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n")}`, "info");
|
|
112
114
|
} catch (error) {
|
|
113
115
|
ctx.ui.notify(`Gates failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
@@ -126,7 +128,7 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
126
128
|
? "tasks.cancel"
|
|
127
129
|
: "tasks.complete";
|
|
128
130
|
if (operation === "tasks.complete") {
|
|
129
|
-
const result = await callService<Record<string, unknown>, TaskCompletion>(operation, { id: action.row.id });
|
|
131
|
+
const result = await callService<Record<string, unknown>, TaskCompletion>(operation, { id: action.row.id, actor: "user", source: "tasks-tui" });
|
|
130
132
|
action.row.status = result.artifact.status;
|
|
131
133
|
const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target}`).join("\n");
|
|
132
134
|
const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}`).join("\n");
|
|
@@ -141,7 +143,7 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
141
143
|
result.completed ? "info" : "warning",
|
|
142
144
|
);
|
|
143
145
|
} else {
|
|
144
|
-
const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: action.row.id });
|
|
146
|
+
const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: action.row.id, actor: "user", source: "tasks-tui" });
|
|
145
147
|
action.row.status = updated.status;
|
|
146
148
|
ctx.ui.notify(`${updated.id} → [${updated.status}]`, "info");
|
|
147
149
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Db } from "../db.ts";
|
|
2
|
+
import { inTransaction } from "../db.ts";
|
|
3
|
+
import {
|
|
4
|
+
normalizeTaskHistoryQuery,
|
|
5
|
+
validateTaskEvent,
|
|
6
|
+
type AppendTaskEvent,
|
|
7
|
+
type TaskEvent,
|
|
8
|
+
type TaskEventEvidence,
|
|
9
|
+
type TaskEventType,
|
|
10
|
+
type TaskHistoryPage,
|
|
11
|
+
type TaskHistoryQuery,
|
|
12
|
+
type TaskLifecycleStatus,
|
|
13
|
+
} from "../domain/task-event.ts";
|
|
14
|
+
import type { TaskEventStore } from "../ports/task-event-store.ts";
|
|
15
|
+
|
|
16
|
+
interface TaskEventRow {
|
|
17
|
+
id: number;
|
|
18
|
+
task_id: string;
|
|
19
|
+
occurred_at: string;
|
|
20
|
+
event_type: TaskEventType;
|
|
21
|
+
actor: string;
|
|
22
|
+
source: string;
|
|
23
|
+
session_id: string | null;
|
|
24
|
+
reason: string | null;
|
|
25
|
+
from_status: TaskLifecycleStatus | null;
|
|
26
|
+
to_status: TaskLifecycleStatus | null;
|
|
27
|
+
attempt_id: string | null;
|
|
28
|
+
evidence_json: string | null;
|
|
29
|
+
event_schema_version: 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function mapRow(row: TaskEventRow): TaskEvent {
|
|
33
|
+
return {
|
|
34
|
+
id: row.id,
|
|
35
|
+
taskId: row.task_id,
|
|
36
|
+
occurredAt: row.occurred_at,
|
|
37
|
+
type: row.event_type,
|
|
38
|
+
actor: row.actor,
|
|
39
|
+
source: row.source,
|
|
40
|
+
...(row.session_id === null ? {} : { sessionId: row.session_id }),
|
|
41
|
+
...(row.reason === null ? {} : { reason: row.reason }),
|
|
42
|
+
...(row.from_status === null ? {} : { fromStatus: row.from_status }),
|
|
43
|
+
...(row.to_status === null ? {} : { toStatus: row.to_status }),
|
|
44
|
+
...(row.attempt_id === null ? {} : { attemptId: row.attempt_id }),
|
|
45
|
+
...(row.evidence_json === null ? {} : { evidence: JSON.parse(row.evidence_json) as TaskEventEvidence }),
|
|
46
|
+
schemaVersion: row.event_schema_version,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class SQLiteTaskEventStore implements TaskEventStore {
|
|
51
|
+
constructor(private readonly db: Db) {}
|
|
52
|
+
|
|
53
|
+
atomic<T>(operation: () => T): T { return inTransaction(this.db, operation); }
|
|
54
|
+
|
|
55
|
+
append(input: AppendTaskEvent): TaskEvent {
|
|
56
|
+
const event = validateTaskEvent(input);
|
|
57
|
+
const result = this.db.prepare(`
|
|
58
|
+
INSERT INTO task_events (
|
|
59
|
+
task_id, occurred_at, event_type, actor, source, session_id, reason,
|
|
60
|
+
from_status, to_status, attempt_id, evidence_json, event_schema_version
|
|
61
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
|
62
|
+
`).run(
|
|
63
|
+
event.taskId,
|
|
64
|
+
new Date().toISOString(),
|
|
65
|
+
event.type,
|
|
66
|
+
event.actor,
|
|
67
|
+
event.source,
|
|
68
|
+
event.sessionId ?? null,
|
|
69
|
+
event.reason ?? null,
|
|
70
|
+
event.fromStatus ?? null,
|
|
71
|
+
event.toStatus ?? null,
|
|
72
|
+
event.attemptId ?? null,
|
|
73
|
+
event.evidence === undefined ? null : JSON.stringify(event.evidence),
|
|
74
|
+
);
|
|
75
|
+
return mapRow(this.db.prepare("SELECT * FROM task_events WHERE id = ?").get(result.lastInsertRowid) as TaskEventRow);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
history(taskId: string, query: TaskHistoryQuery = {}): TaskHistoryPage {
|
|
79
|
+
const { limit, direction, cursor } = normalizeTaskHistoryQuery(query);
|
|
80
|
+
const comparator = direction === "desc" ? "<" : ">";
|
|
81
|
+
const order = direction === "desc" ? "DESC" : "ASC";
|
|
82
|
+
const rows = this.db.prepare(`
|
|
83
|
+
SELECT * FROM task_events
|
|
84
|
+
WHERE task_id = ? ${cursor === undefined ? "" : `AND id ${comparator} ?`}
|
|
85
|
+
ORDER BY occurred_at ${order}, id ${order}
|
|
86
|
+
LIMIT ?
|
|
87
|
+
`).all(...(cursor === undefined ? [taskId, limit + 1] : [taskId, cursor, limit + 1])) as TaskEventRow[];
|
|
88
|
+
const hasMore = rows.length > limit;
|
|
89
|
+
const events = rows.slice(0, limit).map(mapRow);
|
|
90
|
+
return { events, ...(hasMore ? { nextCursor: events.at(-1)!.id } : {}) };
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -56,11 +56,12 @@ function installService(): void {
|
|
|
56
56
|
const USAGE = `Usage:
|
|
57
57
|
papyrus serve
|
|
58
58
|
papyrus service <install|start|stop|restart|status>
|
|
59
|
-
papyrus migrate task-
|
|
59
|
+
papyrus migrate task-history [--json]
|
|
60
60
|
papyrus skills run <id> [--arguments-json <json>] [--run-id <id>] [--json]
|
|
61
61
|
papyrus tasks plan [--json]
|
|
62
62
|
papyrus tasks graph [--json]
|
|
63
63
|
papyrus tasks active [--json]
|
|
64
|
+
papyrus tasks history <id> [--json]
|
|
64
65
|
papyrus tasks focus <id> [--json]
|
|
65
66
|
papyrus tasks complete <id> [--json]
|
|
66
67
|
papyrus tasks start <id> [--json]
|
|
@@ -106,8 +107,8 @@ function planText(plan: TaskExecutionPlan): string {
|
|
|
106
107
|
export async function runMigrationCli(args: string[], client: TaskCliClient): Promise<string> {
|
|
107
108
|
const json = args.includes("--json");
|
|
108
109
|
const positional = args.filter((arg) => arg !== "--json");
|
|
109
|
-
if (positional.length !== 1 || positional[0] !== "task-
|
|
110
|
-
throw new Error("migrate requires exactly `task-
|
|
110
|
+
if (positional.length !== 1 || positional[0] !== "task-history") {
|
|
111
|
+
throw new Error("migrate requires exactly `task-history`");
|
|
111
112
|
}
|
|
112
113
|
const result = await client.call<Record<string, never>, MigrationResult>("system.migrate", {});
|
|
113
114
|
if (json) return JSON.stringify(result);
|
|
@@ -174,6 +175,15 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
174
175
|
human = active ? `Active: ${artifactLabel(active)}` : "No active task.";
|
|
175
176
|
break;
|
|
176
177
|
}
|
|
178
|
+
case "history": {
|
|
179
|
+
if (!id || dependencyId) throw new Error("tasks history requires exactly one task id");
|
|
180
|
+
const page = await client.call<{ id: string; direction: "desc" }, import("./domain/task-event.ts").TaskHistoryPage>("tasks.history", { id, direction: "desc" });
|
|
181
|
+
result = page;
|
|
182
|
+
human = page.events.length === 0
|
|
183
|
+
? `No recorded history for ${id}.`
|
|
184
|
+
: [...page.events].reverse().map((event) => `${event.occurredAt} ${event.type} ${event.fromStatus ?? "∅"} → ${event.toStatus ?? "∅"} · ${event.actor}/${event.source}${event.reason ? ` · ${event.reason}` : ""}`).join("\n");
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
177
187
|
case "focus": {
|
|
178
188
|
if (!id || dependencyId) throw new Error("tasks focus requires exactly one task id");
|
|
179
189
|
const active = await client.call<{ id: string }, CliArtifact>("tasks.focus", { id });
|
|
@@ -202,7 +212,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
202
212
|
}
|
|
203
213
|
case "complete": {
|
|
204
214
|
if (!id || dependencyId) throw new Error("tasks complete requires exactly one task id");
|
|
205
|
-
const completion = await client.call<
|
|
215
|
+
const completion = await client.call<Record<string, string>, CliCompletion>("tasks.complete", { id, actor: "user", source: "cli" });
|
|
206
216
|
result = completion;
|
|
207
217
|
const lines = [`${completion.completed ? "Completed" : "Rejected"}: ${artifactLabel(completion.artifact)}`];
|
|
208
218
|
if (completion.focused) lines.push(`Active: ${artifactLabel(completion.focused)}`);
|
|
@@ -215,7 +225,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
215
225
|
}
|
|
216
226
|
case "start": {
|
|
217
227
|
if (!id || dependencyId) throw new Error("tasks start requires exactly one task id");
|
|
218
|
-
const artifact = await client.call<
|
|
228
|
+
const artifact = await client.call<Record<string, string>, CliArtifact>("tasks.start", { id, actor: "user", source: "cli" });
|
|
219
229
|
result = artifact;
|
|
220
230
|
human = `Started: ${artifactLabel(artifact)}`;
|
|
221
231
|
break;
|
|
@@ -226,7 +236,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
226
236
|
case "cancel": {
|
|
227
237
|
if (!id || dependencyId) throw new Error(`tasks ${action} requires exactly one task id`);
|
|
228
238
|
const operation = `tasks.${action}` as "tasks.submit" | "tasks.reject" | "tasks.retry" | "tasks.cancel";
|
|
229
|
-
const artifact = await client.call<
|
|
239
|
+
const artifact = await client.call<Record<string, string>, CliArtifact>(operation, { id, actor: "user", source: "cli" });
|
|
230
240
|
result = artifact;
|
|
231
241
|
human = `${action[0]!.toUpperCase()}${action.slice(1)}: ${artifactLabel(artifact)}`;
|
|
232
242
|
break;
|
|
@@ -242,7 +252,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient): Promise
|
|
|
242
252
|
break;
|
|
243
253
|
}
|
|
244
254
|
default:
|
|
245
|
-
throw new Error("tasks action must be active, focus, graph, plan, complete, start, submit, reject, retry, cancel, or depend");
|
|
255
|
+
throw new Error("tasks action must be active, focus, graph, plan, history, complete, start, submit, reject, retry, cancel, or depend");
|
|
246
256
|
}
|
|
247
257
|
return json ? JSON.stringify(result) : human;
|
|
248
258
|
}
|
package/src/constants.ts
CHANGED
|
@@ -7,7 +7,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
|
|
|
7
7
|
export const DAEMON_UNIT_NAME = "papyrus.service";
|
|
8
8
|
export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
|
|
9
9
|
export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
|
10
|
-
export const SQLITE_SCHEMA_VERSION =
|
|
10
|
+
export const SQLITE_SCHEMA_VERSION = 3;
|
|
11
11
|
export const SERVICE_MAX_BODY_BYTES = 1_048_576;
|
|
12
12
|
export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
|
|
13
13
|
export const DB_OPTIMIZE_INTERVAL_MS = 24 * 60 * 60_000;
|
|
@@ -42,6 +42,12 @@ export const SKILL_RUN_ID_MAX_LENGTH = 64;
|
|
|
42
42
|
/** Bounded automatic Pi continuations while a focused Papyrus Task remains. */
|
|
43
43
|
export const TASK_DRIVER_MAX_TURNS = 20;
|
|
44
44
|
export const TASK_DRIVER_MAX_UNCHANGED_TURNS = 6;
|
|
45
|
+
/** Append-only Task chronology query and evidence bounds. */
|
|
46
|
+
export const TASK_HISTORY_DEFAULT_LIMIT = 25;
|
|
47
|
+
export const TASK_HISTORY_MAX_LIMIT = 100;
|
|
48
|
+
export const TASK_EVENT_MAX_EVIDENCE_BYTES = 65_536;
|
|
49
|
+
export const TASK_EVENT_ACTOR_MAX_LENGTH = 128;
|
|
50
|
+
export const TASK_EVENT_REASON_MAX_LENGTH = 2_000;
|
|
45
51
|
export const GRAPH_RENDER_PADDING_X = 2;
|
|
46
52
|
export const GRAPH_RENDER_PADDING_Y = 1;
|
|
47
53
|
export const GRAPH_RENDER_BOX_PADDING = 0;
|
package/src/db.ts
CHANGED
|
@@ -102,6 +102,26 @@ CREATE TABLE IF NOT EXISTS task_focus (
|
|
|
102
102
|
task_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
|
|
103
103
|
updated_at TEXT NOT NULL
|
|
104
104
|
);
|
|
105
|
+
CREATE TABLE IF NOT EXISTS task_events (
|
|
106
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
107
|
+
task_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
108
|
+
occurred_at TEXT NOT NULL,
|
|
109
|
+
event_type TEXT NOT NULL,
|
|
110
|
+
actor TEXT NOT NULL,
|
|
111
|
+
source TEXT NOT NULL,
|
|
112
|
+
session_id TEXT,
|
|
113
|
+
reason TEXT,
|
|
114
|
+
from_status TEXT,
|
|
115
|
+
to_status TEXT,
|
|
116
|
+
attempt_id TEXT,
|
|
117
|
+
evidence_json TEXT,
|
|
118
|
+
event_schema_version INTEGER NOT NULL DEFAULT 1
|
|
119
|
+
);
|
|
120
|
+
CREATE INDEX IF NOT EXISTS task_events_history_idx ON task_events(task_id, occurred_at, id);
|
|
121
|
+
CREATE TRIGGER IF NOT EXISTS task_events_no_update BEFORE UPDATE ON task_events
|
|
122
|
+
BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
|
|
123
|
+
CREATE TRIGGER IF NOT EXISTS task_events_no_delete BEFORE DELETE ON task_events
|
|
124
|
+
BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
|
|
105
125
|
`;
|
|
106
126
|
|
|
107
127
|
const SEED_SQL = `
|
|
@@ -165,36 +185,66 @@ export function migrateDb(db: Db): MigrationResult {
|
|
|
165
185
|
throw new Error(`database schema ${from} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
|
|
166
186
|
}
|
|
167
187
|
if (from === SQLITE_SCHEMA_VERSION) return { from, to: from, applied: [] };
|
|
168
|
-
if (from !== 1) throw new Error(`no explicit migration path from database schema ${from}`);
|
|
188
|
+
if (from !== 1 && from !== 2) throw new Error(`no explicit migration path from database schema ${from}`);
|
|
189
|
+
const applied: string[] = [];
|
|
169
190
|
|
|
170
191
|
inTransaction(db, () => {
|
|
171
|
-
db
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
192
|
+
if (schemaVersion(db) === 1) {
|
|
193
|
+
db.exec(`
|
|
194
|
+
INSERT OR IGNORE INTO statuses VALUES ('todo','task');
|
|
195
|
+
INSERT OR IGNORE INTO statuses VALUES ('in-progress','task');
|
|
196
|
+
INSERT OR IGNORE INTO statuses VALUES ('review','task');
|
|
197
|
+
INSERT OR IGNORE INTO statuses VALUES ('rejected','task');
|
|
198
|
+
INSERT OR IGNORE INTO statuses VALUES ('done','task');
|
|
199
|
+
INSERT OR IGNORE INTO statuses VALUES ('canceled','task');
|
|
200
|
+
CREATE TABLE task_focus (
|
|
201
|
+
scope TEXT PRIMARY KEY CHECK (scope = 'global'),
|
|
202
|
+
task_id TEXT NOT NULL UNIQUE REFERENCES artifacts(id),
|
|
203
|
+
updated_at TEXT NOT NULL
|
|
204
|
+
);
|
|
205
|
+
INSERT INTO task_focus (scope, task_id, updated_at)
|
|
206
|
+
SELECT 'global', id, strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
|
207
|
+
FROM artifacts WHERE kind = 'task' AND status = 'active'
|
|
208
|
+
ORDER BY updated_at DESC, id ASC LIMIT 1;
|
|
209
|
+
UPDATE artifacts SET status = CASE status
|
|
210
|
+
WHEN 'pending' THEN 'todo'
|
|
211
|
+
WHEN 'active' THEN 'in-progress'
|
|
212
|
+
WHEN 'failed' THEN 'rejected'
|
|
213
|
+
ELSE status END
|
|
214
|
+
WHERE kind = 'task';
|
|
215
|
+
DELETE FROM statuses WHERE kind = 'task' AND name IN ('pending', 'active', 'failed');
|
|
216
|
+
PRAGMA user_version = 2;
|
|
217
|
+
`);
|
|
218
|
+
applied.push("task-lifecycle-and-focus");
|
|
219
|
+
}
|
|
220
|
+
if (schemaVersion(db) === 2) {
|
|
221
|
+
db.exec(`
|
|
222
|
+
CREATE TABLE task_events (
|
|
223
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
224
|
+
task_id TEXT NOT NULL REFERENCES artifacts(id),
|
|
225
|
+
occurred_at TEXT NOT NULL,
|
|
226
|
+
event_type TEXT NOT NULL,
|
|
227
|
+
actor TEXT NOT NULL,
|
|
228
|
+
source TEXT NOT NULL,
|
|
229
|
+
session_id TEXT,
|
|
230
|
+
reason TEXT,
|
|
231
|
+
from_status TEXT,
|
|
232
|
+
to_status TEXT,
|
|
233
|
+
attempt_id TEXT,
|
|
234
|
+
evidence_json TEXT,
|
|
235
|
+
event_schema_version INTEGER NOT NULL DEFAULT 1
|
|
236
|
+
);
|
|
237
|
+
CREATE INDEX task_events_history_idx ON task_events(task_id, occurred_at, id);
|
|
238
|
+
CREATE TRIGGER task_events_no_update BEFORE UPDATE ON task_events
|
|
239
|
+
BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
|
|
240
|
+
CREATE TRIGGER task_events_no_delete BEFORE DELETE ON task_events
|
|
241
|
+
BEGIN SELECT RAISE(ABORT, 'task_events are append-only'); END;
|
|
242
|
+
PRAGMA user_version = 3;
|
|
243
|
+
`);
|
|
244
|
+
applied.push("task-history");
|
|
245
|
+
}
|
|
196
246
|
});
|
|
197
|
-
return { from, to:
|
|
247
|
+
return { from, to: schemaVersion(db), applied };
|
|
198
248
|
}
|
|
199
249
|
|
|
200
250
|
export function openDb(path: string): Db {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TASK_EVENT_ACTOR_MAX_LENGTH,
|
|
3
|
+
TASK_EVENT_MAX_EVIDENCE_BYTES,
|
|
4
|
+
TASK_EVENT_REASON_MAX_LENGTH,
|
|
5
|
+
TASK_HISTORY_DEFAULT_LIMIT,
|
|
6
|
+
TASK_HISTORY_MAX_LIMIT,
|
|
7
|
+
} from "../constants.ts";
|
|
8
|
+
export type TaskLifecycleStatus = "todo" | "in-progress" | "review" | "rejected" | "done" | "canceled";
|
|
9
|
+
|
|
10
|
+
export const TASK_EVENT_TYPES = [
|
|
11
|
+
"created",
|
|
12
|
+
"started",
|
|
13
|
+
"submitted",
|
|
14
|
+
"completion_attempted",
|
|
15
|
+
"gates_evaluated",
|
|
16
|
+
"review_rejected",
|
|
17
|
+
"retried",
|
|
18
|
+
"completed",
|
|
19
|
+
"canceled",
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
export type TaskEventType = typeof TASK_EVENT_TYPES[number];
|
|
23
|
+
export type TaskEventDirection = "asc" | "desc";
|
|
24
|
+
|
|
25
|
+
export interface TaskEventContext {
|
|
26
|
+
actor?: string;
|
|
27
|
+
source?: string;
|
|
28
|
+
sessionId?: string;
|
|
29
|
+
reason?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TaskEventEvidence {
|
|
33
|
+
gates?: unknown;
|
|
34
|
+
checklist?: unknown;
|
|
35
|
+
result?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TaskEvent {
|
|
39
|
+
id: number;
|
|
40
|
+
taskId: string;
|
|
41
|
+
occurredAt: string;
|
|
42
|
+
type: TaskEventType;
|
|
43
|
+
actor: string;
|
|
44
|
+
source: string;
|
|
45
|
+
sessionId?: string;
|
|
46
|
+
reason?: string;
|
|
47
|
+
fromStatus?: TaskLifecycleStatus;
|
|
48
|
+
toStatus?: TaskLifecycleStatus;
|
|
49
|
+
attemptId?: string;
|
|
50
|
+
evidence?: TaskEventEvidence;
|
|
51
|
+
schemaVersion: 1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AppendTaskEvent {
|
|
55
|
+
taskId: string;
|
|
56
|
+
type: TaskEventType;
|
|
57
|
+
actor: string;
|
|
58
|
+
source: string;
|
|
59
|
+
sessionId?: string;
|
|
60
|
+
reason?: string;
|
|
61
|
+
fromStatus?: TaskLifecycleStatus;
|
|
62
|
+
toStatus?: TaskLifecycleStatus;
|
|
63
|
+
attemptId?: string;
|
|
64
|
+
evidence?: TaskEventEvidence;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface TaskHistoryQuery {
|
|
68
|
+
limit?: number;
|
|
69
|
+
cursor?: number;
|
|
70
|
+
direction?: TaskEventDirection;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface TaskHistoryPage {
|
|
74
|
+
events: TaskEvent[];
|
|
75
|
+
nextCursor?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function normalizeTaskHistoryQuery(query: TaskHistoryQuery = {}): Required<Pick<TaskHistoryQuery, "limit" | "direction">> & Pick<TaskHistoryQuery, "cursor"> {
|
|
79
|
+
const limit = query.limit ?? TASK_HISTORY_DEFAULT_LIMIT;
|
|
80
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > TASK_HISTORY_MAX_LIMIT) {
|
|
81
|
+
throw new Error(`task history limit must be between 1 and ${TASK_HISTORY_MAX_LIMIT}`);
|
|
82
|
+
}
|
|
83
|
+
if (query.cursor !== undefined && (!Number.isInteger(query.cursor) || query.cursor < 1)) {
|
|
84
|
+
throw new Error("task history cursor must be a positive integer");
|
|
85
|
+
}
|
|
86
|
+
if (query.direction !== undefined && query.direction !== "asc" && query.direction !== "desc") {
|
|
87
|
+
throw new Error("task history direction must be asc or desc");
|
|
88
|
+
}
|
|
89
|
+
return { limit, direction: query.direction ?? "desc", ...(query.cursor === undefined ? {} : { cursor: query.cursor }) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function validateTaskEvent(event: AppendTaskEvent): AppendTaskEvent {
|
|
93
|
+
for (const [field, value] of [["actor", event.actor], ["source", event.source]] as const) {
|
|
94
|
+
if (!value || value.length > TASK_EVENT_ACTOR_MAX_LENGTH) throw new Error(`${field} must be between 1 and ${TASK_EVENT_ACTOR_MAX_LENGTH} characters`);
|
|
95
|
+
}
|
|
96
|
+
if (event.sessionId !== undefined && event.sessionId.length > TASK_EVENT_ACTOR_MAX_LENGTH) throw new Error(`sessionId cannot exceed ${TASK_EVENT_ACTOR_MAX_LENGTH} characters`);
|
|
97
|
+
if (event.reason !== undefined && event.reason.length > TASK_EVENT_REASON_MAX_LENGTH) throw new Error(`reason cannot exceed ${TASK_EVENT_REASON_MAX_LENGTH} characters`);
|
|
98
|
+
if (event.evidence !== undefined && new TextEncoder().encode(JSON.stringify(event.evidence)).byteLength > TASK_EVENT_MAX_EVIDENCE_BYTES) {
|
|
99
|
+
throw new Error(`task event evidence cannot exceed ${TASK_EVENT_MAX_EVIDENCE_BYTES} bytes`);
|
|
100
|
+
}
|
|
101
|
+
return event;
|
|
102
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { normalizeTaskHistoryQuery, validateTaskEvent, type AppendTaskEvent, type TaskEvent, type TaskHistoryPage, type TaskHistoryQuery } from "../domain/task-event.ts";
|
|
2
|
+
|
|
3
|
+
export interface TaskEventStore {
|
|
4
|
+
atomic<T>(operation: () => T): T;
|
|
5
|
+
append(event: AppendTaskEvent): TaskEvent;
|
|
6
|
+
history(taskId: string, query?: TaskHistoryQuery): TaskHistoryPage;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class InMemoryTaskEventStore implements TaskEventStore {
|
|
10
|
+
private events: TaskEvent[] = [];
|
|
11
|
+
private nextId = 1;
|
|
12
|
+
|
|
13
|
+
atomic<T>(operation: () => T): T {
|
|
14
|
+
const length = this.events.length;
|
|
15
|
+
const nextId = this.nextId;
|
|
16
|
+
try { return operation(); }
|
|
17
|
+
catch (error) {
|
|
18
|
+
this.events.length = length;
|
|
19
|
+
this.nextId = nextId;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
append(event: AppendTaskEvent): TaskEvent {
|
|
25
|
+
const stored: TaskEvent = {
|
|
26
|
+
...validateTaskEvent(event),
|
|
27
|
+
id: this.nextId++,
|
|
28
|
+
occurredAt: new Date().toISOString(),
|
|
29
|
+
schemaVersion: 1,
|
|
30
|
+
};
|
|
31
|
+
this.events.push(stored);
|
|
32
|
+
return stored;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
history(taskId: string, query: TaskHistoryQuery = {}): TaskHistoryPage {
|
|
36
|
+
const { direction, limit, cursor } = normalizeTaskHistoryQuery(query);
|
|
37
|
+
const ordered = this.events
|
|
38
|
+
.filter((event) => event.taskId === taskId && (cursor === undefined || (direction === "desc" ? event.id < cursor : event.id > cursor)))
|
|
39
|
+
.sort((left, right) => direction === "desc" ? right.id - left.id : left.id - right.id);
|
|
40
|
+
const events = ordered.slice(0, limit);
|
|
41
|
+
return { events, ...(ordered.length > limit ? { nextCursor: events.at(-1)!.id } : {}) };
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -4,10 +4,13 @@ import { migrateDb, openDb, schemaVersion } from "./db.ts";
|
|
|
4
4
|
import { SQLiteArtifactStore } from "./adapters/sqlite-artifact-store.ts";
|
|
5
5
|
import { SQLiteGateRunner } from "./adapters/sqlite-gate-runner.ts";
|
|
6
6
|
import { SQLiteTaskFocusStore } from "./adapters/sqlite-task-focus-store.ts";
|
|
7
|
+
import { SQLiteTaskEventStore } from "./adapters/sqlite-task-event-store.ts";
|
|
7
8
|
import type { CreateArtifactInput } from "./domain/artifact.ts";
|
|
8
9
|
import type { Checklist } from "./domain/checklist.ts";
|
|
10
|
+
import type { TaskEventContext, TaskEventDirection } from "./domain/task-event.ts";
|
|
9
11
|
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
10
12
|
import type { GateRunner } from "./ports/gate-runner.ts";
|
|
13
|
+
import type { TaskEventStore } from "./ports/task-event-store.ts";
|
|
11
14
|
import { projectTaskExecution } from "./task-execution.ts";
|
|
12
15
|
import { Tasks, type TaskStatus } from "./task-service.ts";
|
|
13
16
|
import {
|
|
@@ -50,6 +53,7 @@ export const EXPECTED_OPERATION_NAMES = [
|
|
|
50
53
|
"tasks.graph",
|
|
51
54
|
"tasks.plan",
|
|
52
55
|
"tasks.show",
|
|
56
|
+
"tasks.history",
|
|
53
57
|
"tasks.active",
|
|
54
58
|
"tasks.focus",
|
|
55
59
|
"tasks.start",
|
|
@@ -140,8 +144,19 @@ function handlers(
|
|
|
140
144
|
artifacts: ArtifactStore,
|
|
141
145
|
gates: GateRunner,
|
|
142
146
|
tasks: Tasks,
|
|
147
|
+
events: TaskEventStore,
|
|
143
148
|
migrate: () => unknown,
|
|
144
149
|
): Record<OperationName, OperationHandler> {
|
|
150
|
+
const eventContext = (input: OperationInput): TaskEventContext => ({
|
|
151
|
+
actor: optionalString(input, "actor"),
|
|
152
|
+
source: optionalString(input, "source"),
|
|
153
|
+
sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId"),
|
|
154
|
+
reason: optionalString(input, "reason"),
|
|
155
|
+
});
|
|
156
|
+
const eventContextFor = (input: OperationInput, source: string): TaskEventContext => {
|
|
157
|
+
const context = eventContext(input);
|
|
158
|
+
return { ...context, source: context.source ?? source };
|
|
159
|
+
};
|
|
145
160
|
const taskFilter = (input: OperationInput) => ({
|
|
146
161
|
status: optionalString(input, "status"),
|
|
147
162
|
text: optionalString(input, "text"),
|
|
@@ -149,7 +164,20 @@ function handlers(
|
|
|
149
164
|
});
|
|
150
165
|
return {
|
|
151
166
|
"system.migrate": () => migrate(),
|
|
152
|
-
"artifact.create": (input) =>
|
|
167
|
+
"artifact.create": (input) => {
|
|
168
|
+
const normalized = normalizeCreateInput(input);
|
|
169
|
+
if (normalized.kind !== "task") return artifacts.create(normalized);
|
|
170
|
+
return tasks.create({
|
|
171
|
+
id: normalized.id,
|
|
172
|
+
title: string(input, "title"),
|
|
173
|
+
body: normalized.body,
|
|
174
|
+
subtype: normalized.subtype,
|
|
175
|
+
status: normalized.status as TaskStatus | undefined,
|
|
176
|
+
labels: normalized.labels,
|
|
177
|
+
extra: normalized.extra,
|
|
178
|
+
templateId: normalized.templateId,
|
|
179
|
+
}, eventContextFor(input, "artifact-api"));
|
|
180
|
+
},
|
|
153
181
|
"artifact.query": (input) => artifacts.query(input),
|
|
154
182
|
"artifact.show": (input) => artifacts.get(string(input, "id"), {
|
|
155
183
|
tree: input["tree"] === true,
|
|
@@ -172,8 +200,17 @@ function handlers(
|
|
|
172
200
|
depth: optionalNumber(input, "depth"),
|
|
173
201
|
maxNodes: optionalNumber(input, "max_nodes") ?? optionalNumber(input, "maxNodes"),
|
|
174
202
|
}),
|
|
175
|
-
"graph.status": (input) =>
|
|
176
|
-
|
|
203
|
+
"graph.status": (input) => {
|
|
204
|
+
const id = string(input, "id");
|
|
205
|
+
if (artifacts.get(id)?.kind === "task") throw new Error("task lifecycle changes require a tasks.* operation so history and review invariants are preserved");
|
|
206
|
+
return artifacts.setStatus(id, string(input, "status"));
|
|
207
|
+
},
|
|
208
|
+
"gates.run": (input) => {
|
|
209
|
+
const id = string(input, "id");
|
|
210
|
+
return artifacts.get(id)?.kind === "task"
|
|
211
|
+
? tasks.runGates(id, eventContextFor(input, "gates-api"))
|
|
212
|
+
: gates.runAsync(id);
|
|
213
|
+
},
|
|
177
214
|
"rules.injectable": () => listInjectableRules(artifacts, tasks.active()?.id)
|
|
178
215
|
.map(({ id, title, body, extra }) => ({ id, title, body, extra })),
|
|
179
216
|
"tasks.create": (input) => tasks.create({
|
|
@@ -187,22 +224,27 @@ function handlers(
|
|
|
187
224
|
templateId: optionalString(input, "template_id") ?? optionalString(input, "templateId"),
|
|
188
225
|
parentId: optionalString(input, "parent_id") ?? optionalString(input, "parentId"),
|
|
189
226
|
dependsOn: (input["depends_on"] ?? input["dependsOn"]) as string[] | undefined,
|
|
190
|
-
}),
|
|
227
|
+
}, eventContext(input)),
|
|
191
228
|
"tasks.list": (input) => tasks.list(taskFilter(input)),
|
|
192
229
|
"tasks.graph": (input) => tasks.graph(taskFilter(input)),
|
|
193
230
|
"tasks.plan": (input) => projectTaskExecution(tasks.graph(taskFilter(input))),
|
|
194
231
|
"tasks.show": (input) => tasks.show(string(input, "id")),
|
|
232
|
+
"tasks.history": (input) => tasks.history(string(input, "id"), {
|
|
233
|
+
limit: optionalNumber(input, "limit"),
|
|
234
|
+
cursor: optionalNumber(input, "cursor"),
|
|
235
|
+
direction: optionalString(input, "direction") as TaskEventDirection | undefined,
|
|
236
|
+
}),
|
|
195
237
|
"tasks.active": () => tasks.active(),
|
|
196
238
|
"tasks.focus": (input) => tasks.focus(string(input, "id")),
|
|
197
|
-
"tasks.start": (input) => tasks.transition(string(input, "id"), "start"),
|
|
198
|
-
"tasks.submit": (input) => tasks.transition(string(input, "id"), "submit"),
|
|
199
|
-
"tasks.complete": (input) => tasks.completeAsync(string(input, "id")),
|
|
200
|
-
"tasks.run_gates": (input) => tasks.runGates(string(input, "id")),
|
|
239
|
+
"tasks.start": (input) => tasks.transition(string(input, "id"), "start", eventContext(input)),
|
|
240
|
+
"tasks.submit": (input) => tasks.transition(string(input, "id"), "submit", eventContext(input)),
|
|
241
|
+
"tasks.complete": (input) => tasks.completeAsync(string(input, "id"), eventContext(input)),
|
|
242
|
+
"tasks.run_gates": (input) => tasks.runGates(string(input, "id"), eventContext(input)),
|
|
201
243
|
"tasks.set_checklist": (input) => tasks.setChecklist(string(input, "id"), input["checklist"] as Checklist),
|
|
202
244
|
"tasks.context": () => taskContext(artifacts, tasks.active()?.id),
|
|
203
|
-
"tasks.reject": (input) => tasks.transition(string(input, "id"), "reject"),
|
|
204
|
-
"tasks.retry": (input) => tasks.transition(string(input, "id"), "retry"),
|
|
205
|
-
"tasks.cancel": (input) => tasks.transition(string(input, "id"), "cancel"),
|
|
245
|
+
"tasks.reject": (input) => tasks.transition(string(input, "id"), "reject", eventContext(input)),
|
|
246
|
+
"tasks.retry": (input) => tasks.transition(string(input, "id"), "retry", eventContext(input)),
|
|
247
|
+
"tasks.cancel": (input) => tasks.transition(string(input, "id"), "cancel", eventContext(input)),
|
|
206
248
|
"tasks.depend": (input) => tasks.depend(string(input, "id"), string(input, "dependency_id")),
|
|
207
249
|
"tasks.contain": (input) => tasks.contain(string(input, "parent_id"), string(input, "child_id")),
|
|
208
250
|
"docs.create": (input) => createDocument(artifacts, {
|
|
@@ -244,7 +286,7 @@ function handlers(
|
|
|
244
286
|
"skills.run": (input) => instantiateSkillWorkflow(artifacts, string(input, "id"), {
|
|
245
287
|
runId: optionalString(input, "run_id") ?? optionalString(input, "runId"),
|
|
246
288
|
arguments: input["arguments"] as Record<string, unknown> | undefined,
|
|
247
|
-
}),
|
|
289
|
+
}, { events, context: eventContextFor(input, "skill-run") }),
|
|
248
290
|
"skills.enable": (input) => transitionSkill(artifacts, string(input, "id"), "enable"),
|
|
249
291
|
"skills.disable": (input) => transitionSkill(artifacts, string(input, "id"), "disable"),
|
|
250
292
|
"skills.instantiate": (input) => instantiateTemplate(artifacts, string(input, "template_id"), normalizeCreateInput(input)),
|
|
@@ -256,8 +298,9 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
256
298
|
const artifacts = new SQLiteArtifactStore(db);
|
|
257
299
|
const gates = new SQLiteGateRunner(db);
|
|
258
300
|
const focus = new SQLiteTaskFocusStore(db);
|
|
259
|
-
const
|
|
260
|
-
const
|
|
301
|
+
const events = new SQLiteTaskEventStore(db);
|
|
302
|
+
const tasks = new Tasks(artifacts, gates, focus, events);
|
|
303
|
+
const registry = handlers(artifacts, gates, tasks, events, () => migrateDb(db));
|
|
261
304
|
const state = (): SchemaState => {
|
|
262
305
|
const current = schemaVersion(db);
|
|
263
306
|
return { current, required: SQLITE_SCHEMA_VERSION, migrationRequired: current !== SQLITE_SCHEMA_VERSION };
|
|
@@ -269,7 +312,7 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
269
312
|
const handler = registry[operation as OperationName];
|
|
270
313
|
if (!handler) throw new UnknownOperationError(`unknown operation "${operation}"`);
|
|
271
314
|
if (operation !== "system.migrate" && state().migrationRequired) {
|
|
272
|
-
throw new MigrationRequiredError("database migration required; run `papyrus migrate task-
|
|
315
|
+
throw new MigrationRequiredError("database migration required; run `papyrus migrate task-history`");
|
|
273
316
|
}
|
|
274
317
|
return handler(input);
|
|
275
318
|
},
|
package/src/skill-execution.ts
CHANGED
|
@@ -9,9 +9,11 @@ import {
|
|
|
9
9
|
type SkillDefinition,
|
|
10
10
|
} from "./domain/skill-definition.ts";
|
|
11
11
|
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
12
|
+
import type { TaskEventContext } from "./domain/task-event.ts";
|
|
13
|
+
import type { TaskEventStore } from "./ports/task-event-store.ts";
|
|
12
14
|
import { requireAtomicArtifactStore } from "./ports/atomic-artifact-store.ts";
|
|
13
15
|
import { projectTaskExecution, type TaskExecutionPlan } from "./task-execution.ts";
|
|
14
|
-
import type { TaskGraph, TaskNode } from "./task-service.ts";
|
|
16
|
+
import type { TaskGraph, TaskNode, TaskStatus } from "./task-service.ts";
|
|
15
17
|
|
|
16
18
|
const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
17
19
|
const EXACT_PLACEHOLDER_PATTERN = /^{{\s*([A-Za-z][A-Za-z0-9_-]{0,63})\s*}}$/;
|
|
@@ -115,6 +117,7 @@ export function instantiateSkillWorkflow(
|
|
|
115
117
|
artifacts: ArtifactStore,
|
|
116
118
|
skillId: string,
|
|
117
119
|
input: InstantiateSkillWorkflowInput = {},
|
|
120
|
+
history?: { events: TaskEventStore; context?: TaskEventContext },
|
|
118
121
|
): SkillWorkflowRunResult {
|
|
119
122
|
const { definition } = requireWorkflowSkill(artifacts, skillId);
|
|
120
123
|
const arguments_ = resolveSkillArguments(definition, input.arguments);
|
|
@@ -138,7 +141,7 @@ export function instantiateSkillWorkflow(
|
|
|
138
141
|
}
|
|
139
142
|
|
|
140
143
|
const atomic = requireAtomicArtifactStore(artifacts);
|
|
141
|
-
|
|
144
|
+
const persist = () => atomic.atomic(() => {
|
|
142
145
|
const docs = rendered.blueprints.docs.map((blueprint) => artifacts.create({
|
|
143
146
|
id: ids.get(blueprint.ref),
|
|
144
147
|
kind: "doc",
|
|
@@ -163,14 +166,26 @@ export function instantiateSkillWorkflow(
|
|
|
163
166
|
scope: { type: "skill-run", runId, taskIds },
|
|
164
167
|
},
|
|
165
168
|
}));
|
|
166
|
-
const tasks = rendered.blueprints.tasks.map((blueprint) =>
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
169
|
+
const tasks = rendered.blueprints.tasks.map((blueprint) => {
|
|
170
|
+
const task = artifacts.create({
|
|
171
|
+
id: ids.get(blueprint.ref),
|
|
172
|
+
kind: "task",
|
|
173
|
+
title: blueprint.title,
|
|
174
|
+
body: blueprint.body,
|
|
175
|
+
labels: withRunLabel(blueprint.labels, runId),
|
|
176
|
+
extra: { ...(blueprint.extra ?? {}), skillRun: { id: runId, skillId, ref: blueprint.ref } },
|
|
177
|
+
});
|
|
178
|
+
if (history) history.events.append({
|
|
179
|
+
taskId: task.id,
|
|
180
|
+
type: "created",
|
|
181
|
+
actor: history.context?.actor ?? "system",
|
|
182
|
+
source: history.context?.source ?? "skill-run",
|
|
183
|
+
toStatus: task.status as TaskStatus,
|
|
184
|
+
...(history.context?.sessionId === undefined ? {} : { sessionId: history.context.sessionId }),
|
|
185
|
+
...(history.context?.reason === undefined ? {} : { reason: history.context.reason }),
|
|
186
|
+
});
|
|
187
|
+
return task;
|
|
188
|
+
});
|
|
174
189
|
|
|
175
190
|
for (const blueprint of rendered.blueprints.tasks) {
|
|
176
191
|
const id = ids.get(blueprint.ref)!;
|
|
@@ -201,4 +216,5 @@ export function instantiateSkillWorkflow(
|
|
|
201
216
|
execution: projectTaskExecution(executionGraph(tasks, rendered, ids)),
|
|
202
217
|
};
|
|
203
218
|
});
|
|
219
|
+
return history ? history.events.atomic(persist) : persist();
|
|
204
220
|
}
|
package/src/task-service.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
244
|
+
history(id: string, query: TaskHistoryQuery = {}): TaskHistoryPage {
|
|
228
245
|
this.require(id);
|
|
229
|
-
return this.
|
|
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")
|
|
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
|
|
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}`);
|