@danypops/papyrus 0.2.1 → 0.4.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 +49 -14
- package/extension/src/active-task-continuation.ts +12 -18
- package/extension/src/artifact-format.ts +5 -3
- package/extension/src/beautiful-mermaid-renderer.ts +23 -0
- package/extension/src/domain-tools.ts +41 -7
- package/extension/src/index.ts +21 -36
- package/extension/src/skills.ts +61 -2
- package/extension/src/task-detail-format.ts +5 -3
- package/extension/src/task-graph.ts +22 -3
- package/extension/src/task-presentation.ts +26 -0
- package/extension/src/task-widget.ts +27 -20
- package/extension/src/tasks.ts +68 -31
- package/package.json +1 -1
- package/src/adapters/sqlite-artifact-store.ts +7 -2
- package/src/adapters/sqlite-task-focus-store.ts +31 -0
- package/src/cli.ts +119 -6
- package/src/client.ts +2 -2
- package/src/constants.ts +22 -10
- package/src/db.ts +98 -22
- package/src/domain/skill-definition.ts +15 -11
- package/src/domain-services.ts +31 -0
- package/src/ports/atomic-artifact-store.ts +13 -0
- package/src/ports/task-focus-store.ts +21 -0
- package/src/service.ts +53 -12
- package/src/skill-execution.ts +204 -0
- package/src/task-context.ts +10 -9
- package/src/task-execution.ts +16 -3
- package/src/task-graph-view.ts +6 -3
- package/src/task-service.ts +110 -27
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { SKILL_MAX_RENDERED_BYTES, SKILL_RUN_ID_MAX_LENGTH, TASK_EXECUTION_MAX_EDGES } from "./constants.ts";
|
|
3
|
+
import type { Artifact } from "./domain/artifact.ts";
|
|
4
|
+
import { validateChecklist } from "./domain/checklist.ts";
|
|
5
|
+
import {
|
|
6
|
+
resolveSkillArguments,
|
|
7
|
+
validateSkillDefinition,
|
|
8
|
+
type SkillArgumentValue,
|
|
9
|
+
type SkillDefinition,
|
|
10
|
+
} from "./domain/skill-definition.ts";
|
|
11
|
+
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
12
|
+
import { requireAtomicArtifactStore } from "./ports/atomic-artifact-store.ts";
|
|
13
|
+
import { projectTaskExecution, type TaskExecutionPlan } from "./task-execution.ts";
|
|
14
|
+
import type { TaskGraph, TaskNode } from "./task-service.ts";
|
|
15
|
+
|
|
16
|
+
const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
17
|
+
const EXACT_PLACEHOLDER_PATTERN = /^{{\s*([A-Za-z][A-Za-z0-9_-]{0,63})\s*}}$/;
|
|
18
|
+
const PLACEHOLDER_PATTERN = /{{\s*([A-Za-z][A-Za-z0-9_-]{0,63})\s*}}/g;
|
|
19
|
+
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
20
|
+
|
|
21
|
+
export interface InstantiateSkillWorkflowInput {
|
|
22
|
+
runId?: string;
|
|
23
|
+
arguments?: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SkillWorkflowRunResult {
|
|
27
|
+
skillId: string;
|
|
28
|
+
runId: string;
|
|
29
|
+
arguments: Record<string, SkillArgumentValue>;
|
|
30
|
+
created: {
|
|
31
|
+
docs: string[];
|
|
32
|
+
rules: string[];
|
|
33
|
+
tasks: string[];
|
|
34
|
+
};
|
|
35
|
+
rootTaskIds: string[];
|
|
36
|
+
execution: TaskExecutionPlan;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function requireWorkflowSkill(artifacts: ArtifactStore, skillId: string): { skill: Artifact; definition: SkillDefinition } {
|
|
40
|
+
const skill = artifacts.get(skillId);
|
|
41
|
+
if (!skill) throw new Error(`skill artifact "${skillId}" not found`);
|
|
42
|
+
if (skill.kind !== "skill" || skill.subtype !== "workflow") {
|
|
43
|
+
throw new Error(`artifact "${skillId}" is not a workflow Skill`);
|
|
44
|
+
}
|
|
45
|
+
if (skill.status !== "active") throw new Error(`cannot run workflow Skill from ${skill.status}`);
|
|
46
|
+
return { skill, definition: validateSkillDefinition(skill.extra["definition"]) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeRunId(skillId: string, requested: string | undefined): string {
|
|
50
|
+
const runId = requested ?? `${skillId.slice(0, 40)}-${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
|
51
|
+
if (runId.length > SKILL_RUN_ID_MAX_LENGTH || !RUN_ID_PATTERN.test(runId)) {
|
|
52
|
+
throw new Error(`skill run id must match ${RUN_ID_PATTERN} and contain at most ${SKILL_RUN_ID_MAX_LENGTH} characters`);
|
|
53
|
+
}
|
|
54
|
+
return runId;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function renderValue(value: unknown, arguments_: Record<string, SkillArgumentValue>): unknown {
|
|
58
|
+
if (typeof value === "string") {
|
|
59
|
+
const exact = value.match(EXACT_PLACEHOLDER_PATTERN);
|
|
60
|
+
if (exact) {
|
|
61
|
+
const name = exact[1]!;
|
|
62
|
+
if (!(name in arguments_)) throw new Error(`skill input placeholder "${name}" has no argument value`);
|
|
63
|
+
return arguments_[name]!;
|
|
64
|
+
}
|
|
65
|
+
return value.replace(PLACEHOLDER_PATTERN, (_placeholder, name: string) => {
|
|
66
|
+
if (!(name in arguments_)) throw new Error(`skill input placeholder "${name}" has no argument value`);
|
|
67
|
+
return String(arguments_[name]!);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (Array.isArray(value)) return value.map((entry) => renderValue(entry, arguments_));
|
|
71
|
+
if (typeof value !== "object" || value === null) return value;
|
|
72
|
+
const rendered: Record<string, unknown> = {};
|
|
73
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
74
|
+
if (UNSAFE_KEYS.has(key)) throw new Error(`unsafe skill blueprint key "${key}"`);
|
|
75
|
+
rendered[key] = renderValue(entry, arguments_);
|
|
76
|
+
}
|
|
77
|
+
return rendered;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function renderDefinition(definition: SkillDefinition, arguments_: Record<string, SkillArgumentValue>): SkillDefinition {
|
|
81
|
+
const rendered = renderValue(definition, arguments_) as SkillDefinition;
|
|
82
|
+
const bytes = new TextEncoder().encode(JSON.stringify(rendered)).byteLength;
|
|
83
|
+
if (bytes > SKILL_MAX_RENDERED_BYTES) throw new Error(`rendered skill workflow exceeds ${SKILL_MAX_RENDERED_BYTES} bytes`);
|
|
84
|
+
for (const task of rendered.blueprints.tasks) {
|
|
85
|
+
if (task.extra?.["checklist"] !== undefined) {
|
|
86
|
+
task.extra["checklist"] = validateChecklist(task.extra["checklist"]);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return validateSkillDefinition(rendered);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function withRunLabel(labels: string[] | undefined, runId: string): string[] {
|
|
93
|
+
return [...new Set([...(labels ?? []), `skill-run:${runId}`])];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function executionGraph(tasks: Artifact[], definition: SkillDefinition, ids: Map<string, string>): TaskGraph {
|
|
97
|
+
const byRef = new Map(definition.blueprints.tasks.map((task) => [task.ref, task]));
|
|
98
|
+
const nodes: TaskNode[] = tasks.map((task) => {
|
|
99
|
+
const ref = task.extra["skillRun"] && typeof task.extra["skillRun"] === "object"
|
|
100
|
+
? (task.extra["skillRun"] as Record<string, unknown>)["ref"] as string
|
|
101
|
+
: "";
|
|
102
|
+
const blueprint = byRef.get(ref)!;
|
|
103
|
+
return {
|
|
104
|
+
task,
|
|
105
|
+
active: false,
|
|
106
|
+
parentIds: blueprint.parent ? [ids.get(blueprint.parent)!] : [],
|
|
107
|
+
childIds: definition.blueprints.tasks.filter((candidate) => candidate.parent === ref).map((candidate) => ids.get(candidate.ref)!),
|
|
108
|
+
dependencyIds: (blueprint.dependsOn ?? []).map((dependency) => ids.get(dependency)!),
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
return { nodes, rootIds: nodes.filter((node) => node.parentIds.length === 0).map((node) => node.task.id) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function instantiateSkillWorkflow(
|
|
115
|
+
artifacts: ArtifactStore,
|
|
116
|
+
skillId: string,
|
|
117
|
+
input: InstantiateSkillWorkflowInput = {},
|
|
118
|
+
): SkillWorkflowRunResult {
|
|
119
|
+
const { definition } = requireWorkflowSkill(artifacts, skillId);
|
|
120
|
+
const arguments_ = resolveSkillArguments(definition, input.arguments);
|
|
121
|
+
const rendered = renderDefinition(definition, arguments_);
|
|
122
|
+
const runId = normalizeRunId(skillId, input.runId);
|
|
123
|
+
const refs = [
|
|
124
|
+
...rendered.blueprints.docs.map(({ ref }) => ref),
|
|
125
|
+
...rendered.blueprints.rules.map(({ ref }) => ref),
|
|
126
|
+
...rendered.blueprints.tasks.map(({ ref }) => ref),
|
|
127
|
+
];
|
|
128
|
+
const ids = new Map(refs.map((ref) => [ref, `${runId}-${ref}`]));
|
|
129
|
+
const taskIds = rendered.blueprints.tasks.map(({ ref }) => ids.get(ref)!);
|
|
130
|
+
const rootTaskIds = rendered.blueprints.tasks
|
|
131
|
+
.filter((task) => (task.dependsOn?.length ?? 0) === 0)
|
|
132
|
+
.map((task) => ids.get(task.ref)!);
|
|
133
|
+
const relationshipCount = rendered.links.length
|
|
134
|
+
+ rendered.blueprints.tasks.reduce((count, task) => count + (task.dependsOn?.length ?? 0) + (task.parent ? 2 : 0), 0)
|
|
135
|
+
+ rootTaskIds.length;
|
|
136
|
+
if (relationshipCount > TASK_EXECUTION_MAX_EDGES) {
|
|
137
|
+
throw new Error(`skill workflow run exceeds ${TASK_EXECUTION_MAX_EDGES} relationships`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const atomic = requireAtomicArtifactStore(artifacts);
|
|
141
|
+
return atomic.atomic(() => {
|
|
142
|
+
const docs = rendered.blueprints.docs.map((blueprint) => artifacts.create({
|
|
143
|
+
id: ids.get(blueprint.ref),
|
|
144
|
+
kind: "doc",
|
|
145
|
+
title: blueprint.title,
|
|
146
|
+
body: blueprint.body,
|
|
147
|
+
subtype: blueprint.subtype,
|
|
148
|
+
labels: withRunLabel(blueprint.labels, runId),
|
|
149
|
+
extra: { ...(blueprint.extra ?? {}), skillRun: { id: runId, skillId, ref: blueprint.ref } },
|
|
150
|
+
}));
|
|
151
|
+
const rules = rendered.blueprints.rules.map((blueprint) => artifacts.create({
|
|
152
|
+
id: ids.get(blueprint.ref),
|
|
153
|
+
kind: "rule",
|
|
154
|
+
title: blueprint.title,
|
|
155
|
+
body: blueprint.body,
|
|
156
|
+
labels: withRunLabel(blueprint.labels, runId),
|
|
157
|
+
extra: {
|
|
158
|
+
...(blueprint.extra ?? {}),
|
|
159
|
+
...(blueprint.condition ? { condition: blueprint.condition } : {}),
|
|
160
|
+
...(blueprint.action ? { action: blueprint.action } : {}),
|
|
161
|
+
...(blueprint.severity ? { severity: blueprint.severity } : {}),
|
|
162
|
+
skillRun: { id: runId, skillId, ref: blueprint.ref },
|
|
163
|
+
scope: { type: "skill-run", runId, taskIds },
|
|
164
|
+
},
|
|
165
|
+
}));
|
|
166
|
+
const tasks = rendered.blueprints.tasks.map((blueprint) => artifacts.create({
|
|
167
|
+
id: ids.get(blueprint.ref),
|
|
168
|
+
kind: "task",
|
|
169
|
+
title: blueprint.title,
|
|
170
|
+
body: blueprint.body,
|
|
171
|
+
labels: withRunLabel(blueprint.labels, runId),
|
|
172
|
+
extra: { ...(blueprint.extra ?? {}), skillRun: { id: runId, skillId, ref: blueprint.ref } },
|
|
173
|
+
}));
|
|
174
|
+
|
|
175
|
+
for (const blueprint of rendered.blueprints.tasks) {
|
|
176
|
+
const id = ids.get(blueprint.ref)!;
|
|
177
|
+
for (const dependency of blueprint.dependsOn ?? []) {
|
|
178
|
+
artifacts.link({ from: id, relation: "depends_on", to: ids.get(dependency)! });
|
|
179
|
+
}
|
|
180
|
+
if (blueprint.parent) {
|
|
181
|
+
const parentId = ids.get(blueprint.parent)!;
|
|
182
|
+
artifacts.link({ from: parentId, relation: "contains", to: id });
|
|
183
|
+
artifacts.link({ from: id, relation: "part_of", to: parentId });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
for (const link of rendered.links) {
|
|
187
|
+
artifacts.link({ from: ids.get(link.from)!, relation: link.relation, to: ids.get(link.to)! });
|
|
188
|
+
}
|
|
189
|
+
for (const rootTaskId of rootTaskIds) artifacts.link({ from: skillId, relation: "triggers", to: rootTaskId });
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
skillId,
|
|
193
|
+
runId,
|
|
194
|
+
arguments: arguments_,
|
|
195
|
+
created: {
|
|
196
|
+
docs: docs.map(({ id }) => id),
|
|
197
|
+
rules: rules.map(({ id }) => id),
|
|
198
|
+
tasks: tasks.map(({ id }) => id),
|
|
199
|
+
},
|
|
200
|
+
rootTaskIds,
|
|
201
|
+
execution: projectTaskExecution(executionGraph(tasks, rendered, ids)),
|
|
202
|
+
};
|
|
203
|
+
});
|
|
204
|
+
}
|
package/src/task-context.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { Artifact } from "./domain/artifact.ts";
|
|
2
2
|
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
TASK_CONTEXT_CURRENT_LIMIT,
|
|
5
|
+
TASK_CONTEXT_REJECTED_LIMIT,
|
|
6
6
|
TASK_RECONCILIATION_INSTRUCTION,
|
|
7
7
|
} from "./constants.ts";
|
|
8
8
|
|
|
@@ -34,19 +34,20 @@ function renderCurrent(task: Artifact): string[] {
|
|
|
34
34
|
];
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export function taskContext(artifacts: ArtifactStore): string | null {
|
|
37
|
+
export function taskContext(artifacts: ArtifactStore, activeTaskId?: string): string | null {
|
|
38
38
|
const tasks = artifacts.query({ kind: "task" }).sort((left, right) => left.updated_at.localeCompare(right.updated_at));
|
|
39
|
-
const open = tasks.filter((task) => task.status !== "done");
|
|
39
|
+
const open = tasks.filter((task) => task.status !== "done" && task.status !== "canceled");
|
|
40
40
|
if (open.length === 0) return null;
|
|
41
41
|
|
|
42
42
|
const done = tasks.length - open.length;
|
|
43
|
-
const active = open.
|
|
44
|
-
const
|
|
45
|
-
const
|
|
43
|
+
const active = activeTaskId ? open.find((task) => task.id === activeTaskId) : undefined;
|
|
44
|
+
const current = active ? [active] : open.filter((task) => task.status === "in-progress" || task.status === "review").slice(0, TASK_CONTEXT_CURRENT_LIMIT);
|
|
45
|
+
const next = open.find((task) => task.status === "todo");
|
|
46
|
+
const rejected = open.filter((task) => task.status === "rejected").slice(0, TASK_CONTEXT_REJECTED_LIMIT);
|
|
46
47
|
const lines = [`Progress: ${done}/${tasks.length} done`];
|
|
47
|
-
for (const task of
|
|
48
|
+
for (const task of current) lines.push(...renderCurrent(task));
|
|
48
49
|
if (next) lines.push(`Next: ${next.title} (${next.id})`);
|
|
49
|
-
if (
|
|
50
|
+
if (rejected.length > 0) lines.push(`Rejected: ${rejected.map((task) => `${task.title} (${task.id})`).join(", ")}`);
|
|
50
51
|
lines.push("", TASK_RECONCILIATION_INSTRUCTION);
|
|
51
52
|
return lines.join("\n");
|
|
52
53
|
}
|
package/src/task-execution.ts
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import { TASK_EXECUTION_MAX_DEGREE, TASK_EXECUTION_MAX_EDGES, TASK_EXECUTION_MAX_NODES } from "./constants.ts";
|
|
2
2
|
import type { TaskGraph } from "./task-service.ts";
|
|
3
3
|
|
|
4
|
-
export type TaskExecutionState =
|
|
4
|
+
export type TaskExecutionState =
|
|
5
|
+
| "todo"
|
|
6
|
+
| "in-progress"
|
|
7
|
+
| "review"
|
|
8
|
+
| "rejected"
|
|
9
|
+
| "done"
|
|
10
|
+
| "canceled"
|
|
11
|
+
| "ready"
|
|
12
|
+
| "blocked"
|
|
13
|
+
| "invalid";
|
|
5
14
|
|
|
6
15
|
export interface TaskExecutionNode {
|
|
7
16
|
id: string;
|
|
8
17
|
title: string;
|
|
9
18
|
status: string;
|
|
19
|
+
active: boolean;
|
|
10
20
|
state: TaskExecutionState;
|
|
11
21
|
layer: number | null;
|
|
12
22
|
prerequisiteIds: string[];
|
|
@@ -21,8 +31,10 @@ export interface TaskExecutionPlan {
|
|
|
21
31
|
|
|
22
32
|
function executionState(status: string, invalid: boolean, prerequisitesDone: boolean): TaskExecutionState {
|
|
23
33
|
if (invalid) return "invalid";
|
|
24
|
-
if (status === "
|
|
25
|
-
if (
|
|
34
|
+
if (status === "todo") return prerequisitesDone ? "ready" : "blocked";
|
|
35
|
+
if (["in-progress", "review", "rejected", "done", "canceled"].includes(status)) {
|
|
36
|
+
return status as TaskExecutionState;
|
|
37
|
+
}
|
|
26
38
|
return "blocked";
|
|
27
39
|
}
|
|
28
40
|
|
|
@@ -96,6 +108,7 @@ export function projectTaskExecution(graph: TaskGraph): TaskExecutionPlan {
|
|
|
96
108
|
id: node.task.id,
|
|
97
109
|
title: node.task.title,
|
|
98
110
|
status: node.task.status,
|
|
111
|
+
active: node.active === true,
|
|
99
112
|
state,
|
|
100
113
|
layer: layerById.get(node.task.id) ?? null,
|
|
101
114
|
prerequisiteIds,
|
package/src/task-graph-view.ts
CHANGED
|
@@ -5,11 +5,14 @@ import type { TaskGraph } from "./task-service.ts";
|
|
|
5
5
|
export type TaskGraphView = "execution" | "dependencies" | "composition";
|
|
6
6
|
|
|
7
7
|
const EXECUTION_GLYPHS: Record<TaskExecutionState, string> = {
|
|
8
|
+
todo: "○",
|
|
9
|
+
"in-progress": "●",
|
|
10
|
+
review: "◆",
|
|
11
|
+
rejected: "▲",
|
|
8
12
|
done: "■",
|
|
9
|
-
|
|
13
|
+
canceled: "×",
|
|
10
14
|
ready: "◇",
|
|
11
15
|
blocked: "○",
|
|
12
|
-
failed: "▲",
|
|
13
16
|
invalid: "!",
|
|
14
17
|
};
|
|
15
18
|
|
|
@@ -39,7 +42,7 @@ export function projectTaskGraph(graph: TaskGraph, view: TaskGraphView): Display
|
|
|
39
42
|
const nodes = view === "execution"
|
|
40
43
|
? projectTaskExecution(graph).nodes.map((node) => ({
|
|
41
44
|
id: node.id,
|
|
42
|
-
label: `${EXECUTION_GLYPHS[node.state]} ${node.title} · ${node.layer === null ? "no layer" : `layer ${node.layer + 1}`} · ${node.state}`,
|
|
45
|
+
label: `${node.active ? "▶ " : ""}${EXECUTION_GLYPHS[node.state]} ${node.title} · ${node.layer === null ? "no layer" : `layer ${node.layer + 1}`} · ${node.state}`,
|
|
43
46
|
status: node.state,
|
|
44
47
|
}))
|
|
45
48
|
: graph.nodes
|
package/src/task-service.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { TASK_EXECUTION_MAX_DEGREE, TASK_EXECUTION_MAX_EDGES, TASK_EXECUTION_MAX_NODES } from "./constants.ts";
|
|
2
2
|
import type { Artifact } from "./domain/artifact.ts";
|
|
3
|
-
import { validateChecklist, type Checklist } from "./domain/checklist.ts";
|
|
3
|
+
import { checklistEntries, validateChecklist, type Checklist, type ProofReference } from "./domain/checklist.ts";
|
|
4
4
|
import type { Gate, GateResult } from "./domain/gate.ts";
|
|
5
5
|
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
6
6
|
import type { GateRunner } from "./ports/gate-runner.ts";
|
|
7
|
+
import { InMemoryTaskFocusStore, type TaskFocusStore } from "./ports/task-focus-store.ts";
|
|
7
8
|
import { assertDependencyEdgeAllowed } from "./task-execution.ts";
|
|
8
9
|
|
|
9
10
|
export interface TaskFilter {
|
|
@@ -12,10 +13,12 @@ export interface TaskFilter {
|
|
|
12
13
|
limit?: number;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
export type TaskStatus = "todo" | "in-progress" | "review" | "rejected" | "done" | "canceled";
|
|
17
|
+
|
|
15
18
|
export interface CreateTaskInput {
|
|
16
19
|
title: string;
|
|
17
20
|
body?: string;
|
|
18
|
-
status?:
|
|
21
|
+
status?: TaskStatus;
|
|
19
22
|
labels?: string[];
|
|
20
23
|
extra?: Record<string, unknown>;
|
|
21
24
|
gates?: Gate[];
|
|
@@ -25,23 +28,32 @@ export interface CreateTaskInput {
|
|
|
25
28
|
dependsOn?: string[];
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
export type TaskTransition = "start" | "
|
|
31
|
+
export type TaskTransition = "start" | "submit" | "reject" | "retry" | "cancel";
|
|
29
32
|
|
|
30
33
|
export interface TaskBlockage {
|
|
31
34
|
artifact: Artifact;
|
|
32
35
|
dependencyIds: string[];
|
|
33
36
|
}
|
|
34
37
|
|
|
38
|
+
export interface ChecklistReview {
|
|
39
|
+
item: string;
|
|
40
|
+
proof: ProofReference[];
|
|
41
|
+
accepted: boolean;
|
|
42
|
+
reason?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
35
45
|
export interface TaskCompletion {
|
|
36
46
|
artifact: Artifact;
|
|
37
47
|
gates: GateResult[];
|
|
48
|
+
checklist: ChecklistReview[];
|
|
38
49
|
completed: boolean;
|
|
39
|
-
|
|
50
|
+
focused: Artifact | null;
|
|
40
51
|
blocked: TaskBlockage[];
|
|
41
52
|
}
|
|
42
53
|
|
|
43
54
|
export interface TaskNode {
|
|
44
55
|
task: Artifact;
|
|
56
|
+
active?: boolean;
|
|
45
57
|
parentIds: string[];
|
|
46
58
|
childIds: string[];
|
|
47
59
|
dependencyIds: string[];
|
|
@@ -52,16 +64,19 @@ export interface TaskGraph {
|
|
|
52
64
|
rootIds: string[];
|
|
53
65
|
}
|
|
54
66
|
|
|
55
|
-
const TASK_TRANSITIONS: Record<TaskTransition, { from:
|
|
56
|
-
start: { from: ["
|
|
57
|
-
|
|
58
|
-
|
|
67
|
+
const TASK_TRANSITIONS: Record<TaskTransition, { from: TaskStatus[]; to: TaskStatus }> = {
|
|
68
|
+
start: { from: ["todo"], to: "in-progress" },
|
|
69
|
+
submit: { from: ["in-progress"], to: "review" },
|
|
70
|
+
reject: { from: ["review"], to: "rejected" },
|
|
71
|
+
retry: { from: ["rejected"], to: "in-progress" },
|
|
72
|
+
cancel: { from: ["todo", "in-progress", "review", "rejected"], to: "canceled" },
|
|
59
73
|
};
|
|
60
74
|
|
|
61
75
|
export class Tasks {
|
|
62
76
|
constructor(
|
|
63
77
|
private readonly artifacts: ArtifactStore,
|
|
64
78
|
private readonly gates: GateRunner,
|
|
79
|
+
private readonly focusStore: TaskFocusStore = new InMemoryTaskFocusStore(),
|
|
65
80
|
) {}
|
|
66
81
|
|
|
67
82
|
private require(id: string): Artifact {
|
|
@@ -108,8 +123,10 @@ export class Tasks {
|
|
|
108
123
|
throw new Error(`task execution graph exceeds ${TASK_EXECUTION_MAX_NODES} nodes`);
|
|
109
124
|
}
|
|
110
125
|
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
126
|
+
const focusedId = this.focusStore.get();
|
|
111
127
|
const nodes = new Map(tasks.map((task) => [task.id, {
|
|
112
128
|
task,
|
|
129
|
+
active: task.id === focusedId,
|
|
113
130
|
parentIds: [] as string[],
|
|
114
131
|
childIds: [] as string[],
|
|
115
132
|
dependencyIds: [] as string[],
|
|
@@ -148,34 +165,63 @@ export class Tasks {
|
|
|
148
165
|
return this.artifacts.get(id, { tree: true })!;
|
|
149
166
|
}
|
|
150
167
|
|
|
168
|
+
active(): Artifact | null {
|
|
169
|
+
const id = this.focusStore.get();
|
|
170
|
+
if (!id) return null;
|
|
171
|
+
const task = this.artifacts.get(id);
|
|
172
|
+
if (!task || task.kind !== "task" || task.status === "done" || task.status === "canceled") {
|
|
173
|
+
this.focusStore.clear(id);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
return task;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
focus(id: string): Artifact {
|
|
180
|
+
const task = this.require(id);
|
|
181
|
+
if (task.status === "done" || task.status === "canceled") {
|
|
182
|
+
throw new Error(`cannot focus task from ${task.status}`);
|
|
183
|
+
}
|
|
184
|
+
this.focusStore.set(id);
|
|
185
|
+
return task;
|
|
186
|
+
}
|
|
187
|
+
|
|
151
188
|
transition(id: string, action: TaskTransition): Artifact {
|
|
152
189
|
const task = this.require(id);
|
|
153
190
|
const transition = TASK_TRANSITIONS[action];
|
|
154
|
-
if (!transition.from.includes(task.status)) throw new Error(`cannot ${action} task from ${task.status}`);
|
|
191
|
+
if (!transition.from.includes(task.status as TaskStatus)) throw new Error(`cannot ${action} task from ${task.status}`);
|
|
155
192
|
if (action === "start") {
|
|
156
193
|
const blocking = this.dependencyIds(id).filter((dependencyId) => this.require(dependencyId).status !== "done");
|
|
157
194
|
if (blocking.length > 0) throw new Error(`task "${id}" is blocked by dependencies: ${blocking.join(", ")}`);
|
|
195
|
+
this.focusStore.set(id);
|
|
158
196
|
}
|
|
159
|
-
|
|
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;
|
|
160
202
|
}
|
|
161
203
|
|
|
162
204
|
complete(id: string): TaskCompletion {
|
|
163
|
-
const task = this.
|
|
205
|
+
const task = this.requireReview(id);
|
|
206
|
+
const checklist = this.reviewChecklist(task);
|
|
164
207
|
const results = this.gates.run(id);
|
|
165
|
-
if (results.some((gate) => !gate.passed)) {
|
|
166
|
-
|
|
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: [] };
|
|
167
211
|
}
|
|
168
|
-
return this.finish(id, results);
|
|
212
|
+
return this.finish(id, results, checklist);
|
|
169
213
|
}
|
|
170
214
|
|
|
171
215
|
async completeAsync(id: string): Promise<TaskCompletion> {
|
|
172
|
-
this.
|
|
216
|
+
const task = this.requireReview(id);
|
|
217
|
+
const checklist = this.reviewChecklist(task);
|
|
173
218
|
const results = await this.gates.runAsync(id);
|
|
174
|
-
if (results.some((gate) => !gate.passed)) {
|
|
175
|
-
|
|
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: [] };
|
|
176
222
|
}
|
|
177
|
-
const current = this.
|
|
178
|
-
return this.finish(current.id, results);
|
|
223
|
+
const current = this.requireReview(id);
|
|
224
|
+
return this.finish(current.id, results, checklist);
|
|
179
225
|
}
|
|
180
226
|
|
|
181
227
|
runGates(id: string): Promise<GateResult[]> {
|
|
@@ -226,6 +272,39 @@ export class Tasks {
|
|
|
226
272
|
return relationships;
|
|
227
273
|
}
|
|
228
274
|
|
|
275
|
+
private parentIds(id: string): string[] {
|
|
276
|
+
return this.relationships(id)
|
|
277
|
+
.flatMap((edge) => {
|
|
278
|
+
if (edge.relation === "part_of" && edge.from === id) return [edge.to];
|
|
279
|
+
if (edge.relation === "contains" && edge.to === id) return [edge.from];
|
|
280
|
+
return [];
|
|
281
|
+
})
|
|
282
|
+
.filter((parentId, index, ids) => ids.indexOf(parentId) === index);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private propagateProgressToAncestors(id: string): void {
|
|
286
|
+
const pending = this.parentIds(id);
|
|
287
|
+
const visited = new Set<string>();
|
|
288
|
+
while (pending.length > 0) {
|
|
289
|
+
const parentId = pending.shift()!;
|
|
290
|
+
if (visited.has(parentId)) continue;
|
|
291
|
+
if (visited.size >= TASK_EXECUTION_MAX_NODES) throw new Error("task ancestry exceeds execution node bound");
|
|
292
|
+
visited.add(parentId);
|
|
293
|
+
const parent = this.require(parentId);
|
|
294
|
+
if (parent.status === "todo") this.artifacts.setStatus(parentId, "in-progress");
|
|
295
|
+
pending.push(...this.parentIds(parentId));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private reviewChecklist(task: Artifact): ChecklistReview[] {
|
|
300
|
+
return checklistEntries(task.extra["checklist"]).map((entry) => ({
|
|
301
|
+
item: entry.item,
|
|
302
|
+
proof: entry.proof,
|
|
303
|
+
accepted: !entry.legacy && entry.proof.length > 0,
|
|
304
|
+
...((entry.legacy || entry.proof.length === 0) ? { reason: "typed proof reference required" } : {}),
|
|
305
|
+
}));
|
|
306
|
+
}
|
|
307
|
+
|
|
229
308
|
private dependencyIds(id: string): string[] {
|
|
230
309
|
const ids = this.relationships(id)
|
|
231
310
|
.filter((edge) => edge.relation === "depends_on" && edge.from === id)
|
|
@@ -236,7 +315,7 @@ export class Tasks {
|
|
|
236
315
|
return ids;
|
|
237
316
|
}
|
|
238
317
|
|
|
239
|
-
private finish(id: string, gates: GateResult[]): TaskCompletion {
|
|
318
|
+
private finish(id: string, gates: GateResult[], checklist: ChecklistReview[]): TaskCompletion {
|
|
240
319
|
const successorIds = this.relationships(id)
|
|
241
320
|
.filter((edge) => edge.relation === "depends_on" && edge.to === id)
|
|
242
321
|
.map((edge) => edge.from);
|
|
@@ -244,25 +323,29 @@ export class Tasks {
|
|
|
244
323
|
throw new Error(`task "${id}" exceeds ${TASK_EXECUTION_MAX_DEGREE} successors`);
|
|
245
324
|
}
|
|
246
325
|
const artifact = this.artifacts.setStatus(id, "done")!;
|
|
247
|
-
|
|
326
|
+
this.focusStore.clear(id);
|
|
248
327
|
const blocked: TaskBlockage[] = [];
|
|
249
|
-
|
|
328
|
+
let focused: Artifact | null = null;
|
|
329
|
+
for (const successorId of [...successorIds].sort()) {
|
|
250
330
|
const successor = this.require(successorId);
|
|
251
|
-
if (successor.status
|
|
331
|
+
if (successor.status === "done" || successor.status === "canceled") continue;
|
|
252
332
|
const dependencyIds = this.dependencyIds(successorId)
|
|
253
333
|
.filter((dependencyId) => this.require(dependencyId).status !== "done");
|
|
254
334
|
if (dependencyIds.length > 0) {
|
|
255
335
|
blocked.push({ artifact: successor, dependencyIds });
|
|
256
336
|
continue;
|
|
257
337
|
}
|
|
258
|
-
|
|
338
|
+
if (!focused) {
|
|
339
|
+
this.focusStore.set(successor.id);
|
|
340
|
+
focused = successor;
|
|
341
|
+
}
|
|
259
342
|
}
|
|
260
|
-
return { artifact, gates, completed: true,
|
|
343
|
+
return { artifact, gates, checklist, completed: true, focused, blocked };
|
|
261
344
|
}
|
|
262
345
|
|
|
263
|
-
private
|
|
346
|
+
private requireReview(id: string): Artifact {
|
|
264
347
|
const task = this.require(id);
|
|
265
|
-
if (task.status !== "
|
|
348
|
+
if (task.status !== "review") throw new Error(`cannot complete task from ${task.status}`);
|
|
266
349
|
return task;
|
|
267
350
|
}
|
|
268
351
|
}
|