@danypops/papyrus 0.1.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 +139 -0
- package/extension/src/artifact-browser.ts +213 -0
- package/extension/src/artifact-format.ts +82 -0
- package/extension/src/beautiful-mermaid-renderer.ts +45 -0
- package/extension/src/docs.ts +48 -0
- package/extension/src/facade-tools.ts +209 -0
- package/extension/src/index.ts +354 -0
- package/extension/src/rules.ts +44 -0
- package/extension/src/service-client.ts +45 -0
- package/extension/src/skills.ts +60 -0
- package/extension/src/task-context.ts +1 -0
- package/extension/src/task-detail-format.ts +66 -0
- package/extension/src/task-detail-view.ts +111 -0
- package/extension/src/task-graph.ts +97 -0
- package/extension/src/task-widget.ts +49 -0
- package/extension/src/tasks.ts +258 -0
- package/package.json +43 -0
- package/src/adapters/sqlite-artifact-store.ts +64 -0
- package/src/adapters/sqlite-gate-runner.ts +16 -0
- package/src/cli.ts +71 -0
- package/src/client.ts +59 -0
- package/src/constants.ts +113 -0
- package/src/daemon-state.ts +59 -0
- package/src/daemon.ts +41 -0
- package/src/db.ts +138 -0
- package/src/domain/artifact.ts +56 -0
- package/src/domain/checklist.ts +70 -0
- package/src/domain/display-graph.ts +23 -0
- package/src/domain/gate.ts +11 -0
- package/src/facades.ts +215 -0
- package/src/ops.ts +336 -0
- package/src/ports/artifact-store.ts +19 -0
- package/src/ports/gate-runner.ts +6 -0
- package/src/ports/graph-renderer.ts +5 -0
- package/src/service.ts +292 -0
- package/src/task-context.ts +52 -0
- package/src/task-graph-view.ts +34 -0
- package/src/task-relationship-view.ts +39 -0
- package/src/task-service.ts +176 -0
package/src/service.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { SERVICE_MAX_BODY_BYTES, VERSION } from "./constants.ts";
|
|
2
|
+
import { openDb } from "./db.ts";
|
|
3
|
+
import { SQLiteArtifactStore } from "./adapters/sqlite-artifact-store.ts";
|
|
4
|
+
import { SQLiteGateRunner } from "./adapters/sqlite-gate-runner.ts";
|
|
5
|
+
import type { CreateArtifactInput } from "./domain/artifact.ts";
|
|
6
|
+
import type { Checklist } from "./domain/checklist.ts";
|
|
7
|
+
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
8
|
+
import type { GateRunner } from "./ports/gate-runner.ts";
|
|
9
|
+
import { Tasks } from "./task-service.ts";
|
|
10
|
+
import {
|
|
11
|
+
createArtifactTemplate,
|
|
12
|
+
createDocument,
|
|
13
|
+
createRule,
|
|
14
|
+
createSkill,
|
|
15
|
+
linkDocument,
|
|
16
|
+
gateTaskWithRule,
|
|
17
|
+
instantiateTemplate,
|
|
18
|
+
listDocuments,
|
|
19
|
+
listRules,
|
|
20
|
+
listSkills,
|
|
21
|
+
previewRule,
|
|
22
|
+
showDocument,
|
|
23
|
+
showRule,
|
|
24
|
+
showSkill,
|
|
25
|
+
skillInvocation,
|
|
26
|
+
transitionDocument,
|
|
27
|
+
transitionRule,
|
|
28
|
+
transitionSkill,
|
|
29
|
+
type DocumentRelation,
|
|
30
|
+
} from "./facades.ts";
|
|
31
|
+
import { taskContext } from "./task-context.ts";
|
|
32
|
+
|
|
33
|
+
export const EXPECTED_OPERATION_NAMES = [
|
|
34
|
+
"artifact.create",
|
|
35
|
+
"artifact.query",
|
|
36
|
+
"artifact.show",
|
|
37
|
+
"graph.link",
|
|
38
|
+
"graph.tree",
|
|
39
|
+
"graph.status",
|
|
40
|
+
"gates.run",
|
|
41
|
+
"rules.injectable",
|
|
42
|
+
"tasks.create",
|
|
43
|
+
"tasks.list",
|
|
44
|
+
"tasks.graph",
|
|
45
|
+
"tasks.show",
|
|
46
|
+
"tasks.start",
|
|
47
|
+
"tasks.complete",
|
|
48
|
+
"tasks.run_gates",
|
|
49
|
+
"tasks.set_checklist",
|
|
50
|
+
"tasks.context",
|
|
51
|
+
"tasks.fail",
|
|
52
|
+
"tasks.retry",
|
|
53
|
+
"tasks.depend",
|
|
54
|
+
"tasks.contain",
|
|
55
|
+
"docs.create",
|
|
56
|
+
"docs.list",
|
|
57
|
+
"docs.show",
|
|
58
|
+
"docs.activate",
|
|
59
|
+
"docs.archive",
|
|
60
|
+
"docs.reopen",
|
|
61
|
+
"docs.link",
|
|
62
|
+
"rules.create",
|
|
63
|
+
"rules.list",
|
|
64
|
+
"rules.show",
|
|
65
|
+
"rules.preview",
|
|
66
|
+
"rules.enable",
|
|
67
|
+
"rules.disable",
|
|
68
|
+
"rules.gate",
|
|
69
|
+
"skills.create",
|
|
70
|
+
"skills.create_template",
|
|
71
|
+
"skills.list",
|
|
72
|
+
"skills.show",
|
|
73
|
+
"skills.invoke",
|
|
74
|
+
"skills.enable",
|
|
75
|
+
"skills.disable",
|
|
76
|
+
"skills.instantiate",
|
|
77
|
+
] as const;
|
|
78
|
+
|
|
79
|
+
export type OperationName = typeof EXPECTED_OPERATION_NAMES[number];
|
|
80
|
+
type OperationInput = Record<string, unknown>;
|
|
81
|
+
type OperationHandler = (input: OperationInput) => unknown;
|
|
82
|
+
|
|
83
|
+
export class UnknownOperationError extends Error {}
|
|
84
|
+
export class PayloadTooLargeError extends Error {}
|
|
85
|
+
|
|
86
|
+
function string(input: OperationInput, key: string): string {
|
|
87
|
+
const value = input[key];
|
|
88
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${key} is required`);
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function optionalString(input: OperationInput, key: string): string | undefined {
|
|
93
|
+
const value = input[key];
|
|
94
|
+
if (value === undefined) return undefined;
|
|
95
|
+
if (typeof value !== "string") throw new Error(`${key} must be a string`);
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function optionalNumber(input: OperationInput, key: string): number | undefined {
|
|
100
|
+
const value = input[key];
|
|
101
|
+
if (value === undefined) return undefined;
|
|
102
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${key} must be a number`);
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function normalizeCreateInput(input: OperationInput): CreateArtifactInput {
|
|
107
|
+
const { template_id, ...rest } = input;
|
|
108
|
+
return { ...rest, templateId: typeof template_id === "string" ? template_id : undefined } as CreateArtifactInput;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface PapyrusService {
|
|
112
|
+
operationNames(): OperationName[];
|
|
113
|
+
execute(operation: string, input?: OperationInput): Promise<unknown>;
|
|
114
|
+
checkpoint(): void;
|
|
115
|
+
optimize(): void;
|
|
116
|
+
close(): void;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function handlers(artifacts: ArtifactStore, gates: GateRunner, tasks: Tasks): Record<OperationName, OperationHandler> {
|
|
120
|
+
const taskFilter = (input: OperationInput) => ({
|
|
121
|
+
status: optionalString(input, "status"),
|
|
122
|
+
text: optionalString(input, "text"),
|
|
123
|
+
limit: optionalNumber(input, "limit"),
|
|
124
|
+
});
|
|
125
|
+
return {
|
|
126
|
+
"artifact.create": (input) => artifacts.create(normalizeCreateInput(input)),
|
|
127
|
+
"artifact.query": (input) => artifacts.query(input),
|
|
128
|
+
"artifact.show": (input) => artifacts.get(string(input, "id"), {
|
|
129
|
+
tree: input["tree"] === true,
|
|
130
|
+
depth: optionalNumber(input, "depth"),
|
|
131
|
+
maxNodes: optionalNumber(input, "max_nodes") ?? optionalNumber(input, "maxNodes"),
|
|
132
|
+
}),
|
|
133
|
+
"graph.link": (input) => {
|
|
134
|
+
artifacts.link({ from: string(input, "from"), relation: string(input, "relation"), to: string(input, "to") });
|
|
135
|
+
return { ok: true };
|
|
136
|
+
},
|
|
137
|
+
"graph.tree": (input) => artifacts.get(string(input, "id"), {
|
|
138
|
+
tree: true,
|
|
139
|
+
depth: optionalNumber(input, "depth"),
|
|
140
|
+
maxNodes: optionalNumber(input, "max_nodes") ?? optionalNumber(input, "maxNodes"),
|
|
141
|
+
}),
|
|
142
|
+
"graph.status": (input) => artifacts.setStatus(string(input, "id"), string(input, "status")),
|
|
143
|
+
"gates.run": (input) => gates.runAsync(string(input, "id")),
|
|
144
|
+
"rules.injectable": () => artifacts.query({ kind: "rule", status: "active" })
|
|
145
|
+
.map(({ id, title, body, extra }) => ({ id, title, body, extra })),
|
|
146
|
+
"tasks.create": (input) => tasks.create({
|
|
147
|
+
title: string(input, "title"),
|
|
148
|
+
body: optionalString(input, "body"),
|
|
149
|
+
status: optionalString(input, "status") as "pending" | "active" | "done" | "failed" | undefined,
|
|
150
|
+
labels: input["labels"] as string[] | undefined,
|
|
151
|
+
extra: input["extra"] as Record<string, unknown> | undefined,
|
|
152
|
+
gates: input["gates"] as Parameters<Tasks["create"]>[0]["gates"],
|
|
153
|
+
checklist: input["checklist"] as Checklist | undefined,
|
|
154
|
+
templateId: optionalString(input, "template_id") ?? optionalString(input, "templateId"),
|
|
155
|
+
parentId: optionalString(input, "parent_id") ?? optionalString(input, "parentId"),
|
|
156
|
+
dependsOn: (input["depends_on"] ?? input["dependsOn"]) as string[] | undefined,
|
|
157
|
+
}),
|
|
158
|
+
"tasks.list": (input) => tasks.list(taskFilter(input)),
|
|
159
|
+
"tasks.graph": (input) => tasks.graph(taskFilter(input)),
|
|
160
|
+
"tasks.show": (input) => tasks.show(string(input, "id")),
|
|
161
|
+
"tasks.start": (input) => tasks.transition(string(input, "id"), "start"),
|
|
162
|
+
"tasks.complete": (input) => tasks.completeAsync(string(input, "id")),
|
|
163
|
+
"tasks.run_gates": (input) => tasks.runGates(string(input, "id")),
|
|
164
|
+
"tasks.set_checklist": (input) => tasks.setChecklist(string(input, "id"), input["checklist"] as Checklist),
|
|
165
|
+
"tasks.context": () => taskContext(artifacts),
|
|
166
|
+
"tasks.fail": (input) => tasks.transition(string(input, "id"), "fail"),
|
|
167
|
+
"tasks.retry": (input) => tasks.transition(string(input, "id"), "retry"),
|
|
168
|
+
"tasks.depend": (input) => tasks.depend(string(input, "id"), string(input, "dependency_id")),
|
|
169
|
+
"tasks.contain": (input) => tasks.contain(string(input, "parent_id"), string(input, "child_id")),
|
|
170
|
+
"docs.create": (input) => createDocument(artifacts, {
|
|
171
|
+
title: string(input, "title"), body: optionalString(input, "body"), subtype: optionalString(input, "subtype"),
|
|
172
|
+
labels: input["labels"] as string[] | undefined, extra: input["extra"] as Record<string, unknown> | undefined,
|
|
173
|
+
templateId: optionalString(input, "template_id") ?? optionalString(input, "templateId"),
|
|
174
|
+
}),
|
|
175
|
+
"docs.list": (input) => listDocuments(artifacts, taskFilter(input)),
|
|
176
|
+
"docs.show": (input) => showDocument(artifacts, string(input, "id")),
|
|
177
|
+
"docs.activate": (input) => transitionDocument(artifacts, string(input, "id"), "activate"),
|
|
178
|
+
"docs.archive": (input) => transitionDocument(artifacts, string(input, "id"), "archive"),
|
|
179
|
+
"docs.reopen": (input) => transitionDocument(artifacts, string(input, "id"), "reopen"),
|
|
180
|
+
"docs.link": (input) => linkDocument(artifacts, string(input, "id"), string(input, "relation") as DocumentRelation, string(input, "target_id")),
|
|
181
|
+
"rules.create": (input) => createRule(artifacts, {
|
|
182
|
+
title: string(input, "title"), body: optionalString(input, "body"), condition: optionalString(input, "condition"),
|
|
183
|
+
action: optionalString(input, "rule_action") ?? optionalString(input, "governance_action"),
|
|
184
|
+
severity: optionalString(input, "severity") as "block" | "warn" | "info" | undefined,
|
|
185
|
+
labels: input["labels"] as string[] | undefined, extra: input["extra"] as Record<string, unknown> | undefined,
|
|
186
|
+
}),
|
|
187
|
+
"rules.list": (input) => listRules(artifacts, taskFilter(input)),
|
|
188
|
+
"rules.show": (input) => showRule(artifacts, string(input, "id")),
|
|
189
|
+
"rules.preview": (input) => previewRule(artifacts, string(input, "id")),
|
|
190
|
+
"rules.enable": (input) => transitionRule(artifacts, string(input, "id"), "enable"),
|
|
191
|
+
"rules.disable": (input) => transitionRule(artifacts, string(input, "id"), "disable"),
|
|
192
|
+
"rules.gate": (input) => gateTaskWithRule(artifacts, string(input, "id"), string(input, "task_id")),
|
|
193
|
+
"skills.create": (input) => createSkill(artifacts, {
|
|
194
|
+
title: string(input, "title"), body: optionalString(input, "body"), trigger: optionalString(input, "trigger"),
|
|
195
|
+
steps: input["steps"] as string[] | undefined, tools: input["tools"] as string[] | undefined,
|
|
196
|
+
labels: input["labels"] as string[] | undefined, extra: input["extra"] as Record<string, unknown> | undefined,
|
|
197
|
+
}),
|
|
198
|
+
"skills.create_template": (input) => createArtifactTemplate(artifacts, {
|
|
199
|
+
title: string(input, "title"), targetKind: string(input, "target_kind"), defaults: input["defaults"] as Record<string, unknown> | undefined,
|
|
200
|
+
required: input["required"] as string[] | undefined, body: optionalString(input, "body"), labels: input["labels"] as string[] | undefined,
|
|
201
|
+
}),
|
|
202
|
+
"skills.list": (input) => listSkills(artifacts, taskFilter(input)),
|
|
203
|
+
"skills.show": (input) => showSkill(artifacts, string(input, "id")),
|
|
204
|
+
"skills.invoke": (input) => skillInvocation(artifacts, string(input, "id")),
|
|
205
|
+
"skills.enable": (input) => transitionSkill(artifacts, string(input, "id"), "enable"),
|
|
206
|
+
"skills.disable": (input) => transitionSkill(artifacts, string(input, "id"), "disable"),
|
|
207
|
+
"skills.instantiate": (input) => instantiateTemplate(artifacts, string(input, "template_id"), normalizeCreateInput(input)),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function createPapyrusService(path: string): PapyrusService {
|
|
212
|
+
const db = openDb(path);
|
|
213
|
+
const artifacts = new SQLiteArtifactStore(db);
|
|
214
|
+
const gates = new SQLiteGateRunner(db);
|
|
215
|
+
const tasks = new Tasks(artifacts, gates);
|
|
216
|
+
const registry = handlers(artifacts, gates, tasks);
|
|
217
|
+
return {
|
|
218
|
+
operationNames: () => [...EXPECTED_OPERATION_NAMES],
|
|
219
|
+
async execute(operation, input = {}) {
|
|
220
|
+
const handler = registry[operation as OperationName];
|
|
221
|
+
if (!handler) throw new UnknownOperationError(`unknown operation "${operation}"`);
|
|
222
|
+
return handler(input);
|
|
223
|
+
},
|
|
224
|
+
checkpoint: () => { db.exec("PRAGMA wal_checkpoint(PASSIVE)"); },
|
|
225
|
+
optimize: () => { db.exec("PRAGMA optimize"); },
|
|
226
|
+
close: () => {
|
|
227
|
+
db.exec("PRAGMA optimize");
|
|
228
|
+
db.close();
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function json(value: unknown, init?: ResponseInit): Response {
|
|
234
|
+
return Response.json(value, init);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function readOperationBody(request: Request): Promise<{ op?: unknown; input?: unknown }> {
|
|
238
|
+
const declared = Number(request.headers.get("content-length"));
|
|
239
|
+
if (Number.isFinite(declared) && declared > SERVICE_MAX_BODY_BYTES) {
|
|
240
|
+
throw new PayloadTooLargeError(`request exceeds ${SERVICE_MAX_BODY_BYTES} bytes`);
|
|
241
|
+
}
|
|
242
|
+
if (!request.body) return {};
|
|
243
|
+
const reader = request.body.getReader();
|
|
244
|
+
const chunks: Uint8Array[] = [];
|
|
245
|
+
let size = 0;
|
|
246
|
+
for (;;) {
|
|
247
|
+
const { done, value } = await reader.read();
|
|
248
|
+
if (done) break;
|
|
249
|
+
size += value.byteLength;
|
|
250
|
+
if (size > SERVICE_MAX_BODY_BYTES) {
|
|
251
|
+
await reader.cancel();
|
|
252
|
+
throw new PayloadTooLargeError(`request exceeds ${SERVICE_MAX_BODY_BYTES} bytes`);
|
|
253
|
+
}
|
|
254
|
+
chunks.push(value);
|
|
255
|
+
}
|
|
256
|
+
const bytes = new Uint8Array(size);
|
|
257
|
+
let offset = 0;
|
|
258
|
+
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
|
|
259
|
+
return JSON.parse(new TextDecoder().decode(bytes)) as { op?: unknown; input?: unknown };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function createApp(deps: { service: PapyrusService; token: string }): { fetch(request: Request): Promise<Response> } {
|
|
263
|
+
return {
|
|
264
|
+
async fetch(request: Request): Promise<Response> {
|
|
265
|
+
if (request.headers.get("authorization") !== `Bearer ${deps.token}`) {
|
|
266
|
+
return json({ error: "missing or invalid bearer token" }, { status: 401 });
|
|
267
|
+
}
|
|
268
|
+
const url = new URL(request.url);
|
|
269
|
+
if (request.method === "GET" && url.pathname === "/health") {
|
|
270
|
+
return json({ ok: true, version: VERSION });
|
|
271
|
+
}
|
|
272
|
+
if (request.method === "GET" && url.pathname === "/api/v1/ops") {
|
|
273
|
+
return json({ operations: deps.service.operationNames() });
|
|
274
|
+
}
|
|
275
|
+
if (request.method === "POST" && url.pathname === "/api/v1/ops") {
|
|
276
|
+
try {
|
|
277
|
+
const body = await readOperationBody(request);
|
|
278
|
+
if (typeof body.op !== "string") return json({ error: "op is required" }, { status: 400 });
|
|
279
|
+
const input = body.input === undefined ? {} : body.input;
|
|
280
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
281
|
+
return json({ error: "input must be an object" }, { status: 400 });
|
|
282
|
+
}
|
|
283
|
+
return json({ result: await deps.service.execute(body.op, input as OperationInput) });
|
|
284
|
+
} catch (error) {
|
|
285
|
+
const status = error instanceof PayloadTooLargeError ? 413 : error instanceof UnknownOperationError ? 404 : 400;
|
|
286
|
+
return json({ error: error instanceof Error ? error.message : String(error) }, { status });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return json({ error: "not found" }, { status: 404 });
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Artifact } from "./domain/artifact.ts";
|
|
2
|
+
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
3
|
+
import {
|
|
4
|
+
TASK_CONTEXT_ACTIVE_LIMIT,
|
|
5
|
+
TASK_CONTEXT_FAILED_LIMIT,
|
|
6
|
+
TASK_RECONCILIATION_INSTRUCTION,
|
|
7
|
+
} from "./constants.ts";
|
|
8
|
+
|
|
9
|
+
interface Gate {
|
|
10
|
+
type?: unknown;
|
|
11
|
+
target?: unknown;
|
|
12
|
+
expect?: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function gatesFrom(task: Artifact): Gate[] {
|
|
16
|
+
const gates = task.extra["gates"];
|
|
17
|
+
return Array.isArray(gates) ? gates as Gate[] : [];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function renderGate(gate: Gate): string {
|
|
21
|
+
const type = typeof gate.type === "string" ? gate.type : "gate";
|
|
22
|
+
const target = typeof gate.target === "string" ? gate.target : "unspecified";
|
|
23
|
+
const expect = typeof gate.expect === "string" && gate.expect.length > 0 ? ` = ${gate.expect}` : "";
|
|
24
|
+
return `${type}: ${target}${expect}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function renderCurrent(task: Artifact): string[] {
|
|
28
|
+
const desired = task.body.trim() || task.title;
|
|
29
|
+
const gates = gatesFrom(task);
|
|
30
|
+
return [
|
|
31
|
+
`Current: ${task.title} (${task.id})`,
|
|
32
|
+
`Desired: ${desired}`,
|
|
33
|
+
`Verify: ${gates.length > 0 ? gates.map(renderGate).join("; ") : "inspect the desired outcome; no automated gates configured"}`,
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function taskContext(artifacts: ArtifactStore): string | null {
|
|
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");
|
|
40
|
+
if (open.length === 0) return null;
|
|
41
|
+
|
|
42
|
+
const done = tasks.length - open.length;
|
|
43
|
+
const active = open.filter((task) => task.status === "active").slice(0, TASK_CONTEXT_ACTIVE_LIMIT);
|
|
44
|
+
const next = open.find((task) => task.status === "pending");
|
|
45
|
+
const failed = open.filter((task) => task.status === "failed").slice(0, TASK_CONTEXT_FAILED_LIMIT);
|
|
46
|
+
const lines = [`Progress: ${done}/${tasks.length} done`];
|
|
47
|
+
for (const task of active) lines.push(...renderCurrent(task));
|
|
48
|
+
if (next) lines.push(`Next: ${next.title} (${next.id})`);
|
|
49
|
+
if (failed.length > 0) lines.push(`Blocked: ${failed.map((task) => `${task.title} (${task.id})`).join(", ")}`);
|
|
50
|
+
lines.push("", TASK_RECONCILIATION_INSTRUCTION);
|
|
51
|
+
return lines.join("\n");
|
|
52
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { DisplayGraph, DisplayGraphEdge } from "./domain/display-graph.ts";
|
|
2
|
+
import type { TaskGraph } from "./task-service.ts";
|
|
3
|
+
|
|
4
|
+
export type TaskGraphView = "dependencies" | "composition";
|
|
5
|
+
|
|
6
|
+
export function projectTaskGraph(graph: TaskGraph, view: TaskGraphView): DisplayGraph {
|
|
7
|
+
const edges: DisplayGraphEdge[] = [];
|
|
8
|
+
const seen = new Set<string>();
|
|
9
|
+
const addEdge = (edge: DisplayGraphEdge): void => {
|
|
10
|
+
const key = `${edge.from}\u0000${edge.to}\u0000${edge.label ?? ""}`;
|
|
11
|
+
if (seen.has(key)) return;
|
|
12
|
+
seen.add(key);
|
|
13
|
+
edges.push(edge);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
for (const node of graph.nodes) {
|
|
17
|
+
if (view === "dependencies") {
|
|
18
|
+
for (const dependencyId of node.dependencyIds) {
|
|
19
|
+
addEdge({ from: dependencyId, to: node.task.id, label: "unlocks" });
|
|
20
|
+
}
|
|
21
|
+
} else {
|
|
22
|
+
for (const childId of node.childIds) addEdge({ from: node.task.id, to: childId });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const connected = new Set(edges.flatMap((edge) => [edge.from, edge.to]));
|
|
27
|
+
return {
|
|
28
|
+
direction: "TD",
|
|
29
|
+
nodes: graph.nodes
|
|
30
|
+
.filter((node) => connected.has(node.task.id))
|
|
31
|
+
.map((node) => ({ id: node.task.id, label: node.task.title, status: node.task.status })),
|
|
32
|
+
edges: edges.filter((edge) => connected.has(edge.from) && connected.has(edge.to)),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Artifact, ArtifactEdge } from "./domain/artifact.ts";
|
|
2
|
+
import type { DisplayGraph, DisplayGraphEdge, DisplayGraphNode } from "./domain/display-graph.ts";
|
|
3
|
+
import type { TaskGraph } from "./task-service.ts";
|
|
4
|
+
|
|
5
|
+
function normalizeEdge(edge: ArtifactEdge): DisplayGraphEdge {
|
|
6
|
+
if (edge.relation === "part_of") return { from: edge.to, to: edge.from };
|
|
7
|
+
if (edge.relation === "depends_on") return { from: edge.to, to: edge.from, label: "unlocks" };
|
|
8
|
+
if (edge.relation === "contains") return { from: edge.from, to: edge.to };
|
|
9
|
+
return { from: edge.from, to: edge.to, label: edge.relation };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function fallbackLabel(id: string): string {
|
|
13
|
+
return id.replace(/-[a-z0-9]{4}$/i, "").replaceAll("-", " ");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function projectTaskRelationships(task: Artifact, graph?: TaskGraph): DisplayGraph {
|
|
17
|
+
const taskNodes = new Map(graph?.nodes.map((node) => [node.task.id, node.task]) ?? []);
|
|
18
|
+
taskNodes.set(task.id, task);
|
|
19
|
+
const edges: DisplayGraphEdge[] = [];
|
|
20
|
+
const seenEdges = new Set<string>();
|
|
21
|
+
const nodeIds = new Set<string>();
|
|
22
|
+
for (const artifactEdge of task.edges ?? []) {
|
|
23
|
+
const edge = normalizeEdge(artifactEdge);
|
|
24
|
+
const key = `${edge.from}\u0000${edge.to}\u0000${edge.label ?? ""}`;
|
|
25
|
+
if (!seenEdges.has(key)) {
|
|
26
|
+
seenEdges.add(key);
|
|
27
|
+
edges.push(edge);
|
|
28
|
+
}
|
|
29
|
+
nodeIds.add(edge.from);
|
|
30
|
+
nodeIds.add(edge.to);
|
|
31
|
+
}
|
|
32
|
+
const nodes: DisplayGraphNode[] = [...nodeIds].map((id) => {
|
|
33
|
+
const artifact = taskNodes.get(id);
|
|
34
|
+
return artifact
|
|
35
|
+
? { id, label: artifact.title, status: artifact.status }
|
|
36
|
+
: { id, label: fallbackLabel(id) };
|
|
37
|
+
});
|
|
38
|
+
return { direction: "LR", nodes, edges };
|
|
39
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { Artifact } from "./domain/artifact.ts";
|
|
2
|
+
import { validateChecklist, type Checklist } from "./domain/checklist.ts";
|
|
3
|
+
import type { Gate, GateResult } from "./domain/gate.ts";
|
|
4
|
+
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
5
|
+
import type { GateRunner } from "./ports/gate-runner.ts";
|
|
6
|
+
|
|
7
|
+
export interface TaskFilter {
|
|
8
|
+
status?: string;
|
|
9
|
+
text?: string;
|
|
10
|
+
limit?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CreateTaskInput {
|
|
14
|
+
title: string;
|
|
15
|
+
body?: string;
|
|
16
|
+
status?: "pending" | "active" | "done" | "failed";
|
|
17
|
+
labels?: string[];
|
|
18
|
+
extra?: Record<string, unknown>;
|
|
19
|
+
gates?: Gate[];
|
|
20
|
+
checklist?: Checklist;
|
|
21
|
+
templateId?: string;
|
|
22
|
+
parentId?: string;
|
|
23
|
+
dependsOn?: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type TaskTransition = "start" | "fail" | "retry";
|
|
27
|
+
|
|
28
|
+
export interface TaskCompletion {
|
|
29
|
+
artifact: Artifact;
|
|
30
|
+
gates: GateResult[];
|
|
31
|
+
completed: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TaskNode {
|
|
35
|
+
task: Artifact;
|
|
36
|
+
parentIds: string[];
|
|
37
|
+
childIds: string[];
|
|
38
|
+
dependencyIds: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TaskGraph {
|
|
42
|
+
nodes: TaskNode[];
|
|
43
|
+
rootIds: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const TASK_TRANSITIONS: Record<TaskTransition, { from: string[]; to: string }> = {
|
|
47
|
+
start: { from: ["pending"], to: "active" },
|
|
48
|
+
fail: { from: ["pending", "active"], to: "failed" },
|
|
49
|
+
retry: { from: ["failed"], to: "pending" },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export class Tasks {
|
|
53
|
+
constructor(
|
|
54
|
+
private readonly artifacts: ArtifactStore,
|
|
55
|
+
private readonly gates: GateRunner,
|
|
56
|
+
) {}
|
|
57
|
+
|
|
58
|
+
private require(id: string): Artifact {
|
|
59
|
+
const artifact = this.artifacts.get(id);
|
|
60
|
+
if (!artifact) throw new Error(`task artifact "${id}" not found`);
|
|
61
|
+
if (artifact.kind !== "task") throw new Error(`artifact "${id}" is not a task`);
|
|
62
|
+
return artifact;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
create(input: CreateTaskInput): Artifact {
|
|
66
|
+
if (input.parentId) this.require(input.parentId);
|
|
67
|
+
for (const dependency of input.dependsOn ?? []) this.require(dependency);
|
|
68
|
+
const extra: Record<string, unknown> = { ...(input.extra ?? {}) };
|
|
69
|
+
if (input.gates !== undefined) extra["gates"] = input.gates;
|
|
70
|
+
if (input.checklist !== undefined) extra["checklist"] = validateChecklist(input.checklist);
|
|
71
|
+
const task = this.artifacts.create({
|
|
72
|
+
kind: "task",
|
|
73
|
+
title: input.title,
|
|
74
|
+
body: input.body,
|
|
75
|
+
status: input.status,
|
|
76
|
+
labels: input.labels,
|
|
77
|
+
extra,
|
|
78
|
+
templateId: input.templateId,
|
|
79
|
+
});
|
|
80
|
+
if (input.parentId) this.contain(input.parentId, task.id);
|
|
81
|
+
for (const dependency of input.dependsOn ?? []) this.depend(task.id, dependency);
|
|
82
|
+
return this.show(task.id);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
list(filter: TaskFilter = {}): Artifact[] {
|
|
86
|
+
return this.artifacts.query({ kind: "task", ...filter });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
graph(filter: TaskFilter = {}): TaskGraph {
|
|
90
|
+
const tasks = this.list(filter);
|
|
91
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
92
|
+
const nodes = new Map(tasks.map((task) => [task.id, {
|
|
93
|
+
task,
|
|
94
|
+
parentIds: [] as string[],
|
|
95
|
+
childIds: [] as string[],
|
|
96
|
+
dependencyIds: [] as string[],
|
|
97
|
+
}]));
|
|
98
|
+
for (const edge of this.artifacts.relationships({ kind: "task", artifactIds: [...byId.keys()] })) {
|
|
99
|
+
if (!byId.has(edge.from) || !byId.has(edge.to)) continue;
|
|
100
|
+
const parentId = edge.relation === "contains" ? edge.from : edge.relation === "part_of" ? edge.to : undefined;
|
|
101
|
+
const childId = edge.relation === "contains" ? edge.to : edge.relation === "part_of" ? edge.from : undefined;
|
|
102
|
+
if (parentId && childId && parentId !== childId) {
|
|
103
|
+
const parent = nodes.get(parentId)!;
|
|
104
|
+
const child = nodes.get(childId)!;
|
|
105
|
+
if (!parent.childIds.includes(childId)) parent.childIds.push(childId);
|
|
106
|
+
if (!child.parentIds.includes(parentId)) child.parentIds.push(parentId);
|
|
107
|
+
}
|
|
108
|
+
if (edge.relation === "depends_on") {
|
|
109
|
+
const node = nodes.get(edge.from)!;
|
|
110
|
+
if (!node.dependencyIds.includes(edge.to)) node.dependencyIds.push(edge.to);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
nodes: tasks.map((task) => nodes.get(task.id)!),
|
|
115
|
+
rootIds: tasks.filter((task) => nodes.get(task.id)!.parentIds.length === 0).map((task) => task.id),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
show(id: string): Artifact {
|
|
120
|
+
this.require(id);
|
|
121
|
+
return this.artifacts.get(id, { tree: true })!;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
transition(id: string, action: TaskTransition): Artifact {
|
|
125
|
+
const task = this.require(id);
|
|
126
|
+
const transition = TASK_TRANSITIONS[action];
|
|
127
|
+
if (!transition.from.includes(task.status)) throw new Error(`cannot ${action} task from ${task.status}`);
|
|
128
|
+
return this.artifacts.setStatus(id, transition.to)!;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
complete(id: string): TaskCompletion {
|
|
132
|
+
const task = this.requireActive(id);
|
|
133
|
+
const results = this.gates.run(id);
|
|
134
|
+
if (results.some((gate) => !gate.passed)) return { artifact: task, gates: results, completed: false };
|
|
135
|
+
return { artifact: this.artifacts.setStatus(id, "done")!, gates: results, completed: true };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async completeAsync(id: string): Promise<TaskCompletion> {
|
|
139
|
+
this.requireActive(id);
|
|
140
|
+
const results = await this.gates.runAsync(id);
|
|
141
|
+
if (results.some((gate) => !gate.passed)) return { artifact: this.require(id), gates: results, completed: false };
|
|
142
|
+
const current = this.requireActive(id);
|
|
143
|
+
return { artifact: this.artifacts.setStatus(current.id, "done")!, gates: results, completed: true };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
runGates(id: string): Promise<GateResult[]> {
|
|
147
|
+
this.require(id);
|
|
148
|
+
return this.gates.runAsync(id);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
setChecklist(id: string, checklist: Checklist): Artifact {
|
|
152
|
+
const task = this.require(id);
|
|
153
|
+
return this.artifacts.setExtra(id, { ...task.extra, checklist: validateChecklist(checklist) })!;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
depend(id: string, dependencyId: string): Artifact {
|
|
157
|
+
this.require(id);
|
|
158
|
+
this.require(dependencyId);
|
|
159
|
+
this.artifacts.link({ from: id, relation: "depends_on", to: dependencyId });
|
|
160
|
+
return this.show(id);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
contain(parentId: string, childId: string): Artifact {
|
|
164
|
+
this.require(parentId);
|
|
165
|
+
this.require(childId);
|
|
166
|
+
this.artifacts.link({ from: parentId, relation: "contains", to: childId });
|
|
167
|
+
this.artifacts.link({ from: childId, relation: "part_of", to: parentId });
|
|
168
|
+
return this.show(parentId);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private requireActive(id: string): Artifact {
|
|
172
|
+
const task = this.require(id);
|
|
173
|
+
if (task.status !== "active") throw new Error(`cannot complete task from ${task.status}`);
|
|
174
|
+
return task;
|
|
175
|
+
}
|
|
176
|
+
}
|