@danypops/papyrus 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -7
- package/extension/src/beautiful-mermaid-renderer.ts +23 -0
- package/extension/src/domain-tools.ts +39 -8
- package/extension/src/index.ts +17 -26
- package/extension/src/skills.ts +61 -2
- package/extension/src/task-detail-format.ts +24 -1
- package/extension/src/task-detail-view.ts +6 -3
- package/extension/src/task-graph.ts +11 -2
- package/extension/src/tasks.ts +7 -5
- package/package.json +1 -1
- package/src/adapters/sqlite-artifact-store.ts +7 -2
- package/src/adapters/sqlite-task-event-store.ts +92 -0
- package/src/cli.ts +82 -8
- package/src/constants.ts +18 -1
- package/src/db.ts +102 -29
- package/src/domain/skill-definition.ts +15 -11
- package/src/domain/task-event.ts +102 -0
- package/src/domain-services.ts +31 -0
- package/src/ports/atomic-artifact-store.ts +13 -0
- package/src/ports/task-event-store.ts +43 -0
- package/src/service.ts +66 -15
- package/src/skill-execution.ts +220 -0
- package/src/task-service.ts +120 -53
package/src/domain-services.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Artifact, CreateArtifactInput } from "./domain/artifact.ts";
|
|
2
|
+
import { validateSkillDefinition } from "./domain/skill-definition.ts";
|
|
2
3
|
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
3
4
|
|
|
4
5
|
export interface ListFilter {
|
|
@@ -98,6 +99,18 @@ export function listRules(artifacts: ArtifactStore, filter: ListFilter): Artifac
|
|
|
98
99
|
return artifacts.query({ kind: "rule", ...filter });
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
/** Global rules always apply; scoped workflow rules apply only while their run owns active focus. */
|
|
103
|
+
export function listInjectableRules(artifacts: ArtifactStore, activeTaskId?: string): Artifact[] {
|
|
104
|
+
return artifacts.query({ kind: "rule", status: "active" }).filter((rule) => {
|
|
105
|
+
const scope = rule.extra["scope"];
|
|
106
|
+
if (scope === undefined) return true;
|
|
107
|
+
if (typeof scope !== "object" || scope === null || Array.isArray(scope)) return false;
|
|
108
|
+
const value = scope as Record<string, unknown>;
|
|
109
|
+
if (value["type"] !== "skill-run" || !Array.isArray(value["taskIds"])) return false;
|
|
110
|
+
return activeTaskId !== undefined && value["taskIds"].some((id) => id === activeTaskId);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
101
114
|
export function showRule(artifacts: ArtifactStore, id: string): Artifact {
|
|
102
115
|
requireKind(artifacts, id, "rule");
|
|
103
116
|
return artifacts.get(id, { tree: true })!;
|
|
@@ -131,6 +144,7 @@ export interface CreateSkillInput {
|
|
|
131
144
|
trigger?: string;
|
|
132
145
|
steps?: string[];
|
|
133
146
|
tools?: string[];
|
|
147
|
+
definition?: unknown;
|
|
134
148
|
labels?: string[];
|
|
135
149
|
extra?: Record<string, unknown>;
|
|
136
150
|
}
|
|
@@ -147,13 +161,19 @@ export interface CreateArtifactTemplateInput {
|
|
|
147
161
|
export type SkillTransition = "enable" | "disable";
|
|
148
162
|
|
|
149
163
|
export function createSkill(artifacts: ArtifactStore, input: CreateSkillInput): Artifact {
|
|
164
|
+
if (input.definition !== undefined && (input.trigger !== undefined || input.steps !== undefined || input.tools !== undefined)) {
|
|
165
|
+
throw new Error("workflow Skill definition cannot be mixed with legacy trigger, steps, or tools");
|
|
166
|
+
}
|
|
167
|
+
const definition = input.definition === undefined ? undefined : validateSkillDefinition(input.definition);
|
|
150
168
|
return artifacts.create({
|
|
151
169
|
kind: "skill",
|
|
170
|
+
subtype: definition ? "workflow" : undefined,
|
|
152
171
|
title: input.title,
|
|
153
172
|
body: input.body,
|
|
154
173
|
labels: input.labels,
|
|
155
174
|
extra: {
|
|
156
175
|
...(input.extra ?? {}),
|
|
176
|
+
...(definition ? { definition } : {}),
|
|
157
177
|
...(input.trigger ? { trigger: input.trigger } : {}),
|
|
158
178
|
...(input.steps ? { steps: input.steps } : {}),
|
|
159
179
|
...(input.tools ? { tools: input.tools } : {}),
|
|
@@ -194,6 +214,17 @@ export function skillInvocation(artifacts: ArtifactStore, id: string): string {
|
|
|
194
214
|
if (skill.subtype === "artifact-template") {
|
|
195
215
|
return `Create an artifact using Papyrus template "${skill.title}".\ntemplate_id: ${skill.id}\nAsk for or infer all required template fields, then call the skills domain tool instantiate action.`;
|
|
196
216
|
}
|
|
217
|
+
if (skill.subtype === "workflow") {
|
|
218
|
+
const definition = validateSkillDefinition(skill.extra["definition"]);
|
|
219
|
+
const required = Object.entries(definition.inputs)
|
|
220
|
+
.filter(([, input]) => input.required && input.default === undefined)
|
|
221
|
+
.map(([name]) => name);
|
|
222
|
+
return [
|
|
223
|
+
`Run Papyrus workflow Skill "${skill.title}" (${skill.id}).`,
|
|
224
|
+
`Required arguments: ${required.length > 0 ? required.join(", ") : "none"}.`,
|
|
225
|
+
"Call the skills domain tool with action=run and arguments after collecting required values.",
|
|
226
|
+
].join("\n");
|
|
227
|
+
}
|
|
197
228
|
const trigger = typeof skill.extra["trigger"] === "string" ? skill.extra["trigger"] : "manual invocation";
|
|
198
229
|
const steps = Array.isArray(skill.extra["steps"]) ? skill.extra["steps"].filter((step): step is string => typeof step === "string") : [];
|
|
199
230
|
const tools = Array.isArray(skill.extra["tools"]) ? skill.extra["tools"].filter((tool): tool is string => typeof tool === "string") : [];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ArtifactStore } from "./artifact-store.ts";
|
|
2
|
+
|
|
3
|
+
/** Artifact store boundary for domain operations that must commit as one graph mutation. */
|
|
4
|
+
export interface AtomicArtifactStore extends ArtifactStore {
|
|
5
|
+
atomic<T>(operation: () => T): T;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function requireAtomicArtifactStore(store: ArtifactStore): AtomicArtifactStore {
|
|
9
|
+
if (!("atomic" in store) || typeof store.atomic !== "function") {
|
|
10
|
+
throw new Error("artifact store does not support atomic workflow runs");
|
|
11
|
+
}
|
|
12
|
+
return store as AtomicArtifactStore;
|
|
13
|
+
}
|
|
@@ -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 {
|
|
@@ -20,6 +23,7 @@ import {
|
|
|
20
23
|
instantiateTemplate,
|
|
21
24
|
listDocuments,
|
|
22
25
|
listRules,
|
|
26
|
+
listInjectableRules,
|
|
23
27
|
listSkills,
|
|
24
28
|
previewRule,
|
|
25
29
|
showDocument,
|
|
@@ -32,6 +36,7 @@ import {
|
|
|
32
36
|
type DocumentRelation,
|
|
33
37
|
} from "./domain-services.ts";
|
|
34
38
|
import { taskContext } from "./task-context.ts";
|
|
39
|
+
import { instantiateSkillWorkflow } from "./skill-execution.ts";
|
|
35
40
|
|
|
36
41
|
export const EXPECTED_OPERATION_NAMES = [
|
|
37
42
|
"system.migrate",
|
|
@@ -48,6 +53,7 @@ export const EXPECTED_OPERATION_NAMES = [
|
|
|
48
53
|
"tasks.graph",
|
|
49
54
|
"tasks.plan",
|
|
50
55
|
"tasks.show",
|
|
56
|
+
"tasks.history",
|
|
51
57
|
"tasks.active",
|
|
52
58
|
"tasks.focus",
|
|
53
59
|
"tasks.start",
|
|
@@ -80,6 +86,7 @@ export const EXPECTED_OPERATION_NAMES = [
|
|
|
80
86
|
"skills.list",
|
|
81
87
|
"skills.show",
|
|
82
88
|
"skills.invoke",
|
|
89
|
+
"skills.run",
|
|
83
90
|
"skills.enable",
|
|
84
91
|
"skills.disable",
|
|
85
92
|
"skills.instantiate",
|
|
@@ -137,8 +144,19 @@ function handlers(
|
|
|
137
144
|
artifacts: ArtifactStore,
|
|
138
145
|
gates: GateRunner,
|
|
139
146
|
tasks: Tasks,
|
|
147
|
+
events: TaskEventStore,
|
|
140
148
|
migrate: () => unknown,
|
|
141
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
|
+
};
|
|
142
160
|
const taskFilter = (input: OperationInput) => ({
|
|
143
161
|
status: optionalString(input, "status"),
|
|
144
162
|
text: optionalString(input, "text"),
|
|
@@ -146,7 +164,20 @@ function handlers(
|
|
|
146
164
|
});
|
|
147
165
|
return {
|
|
148
166
|
"system.migrate": () => migrate(),
|
|
149
|
-
"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
|
+
},
|
|
150
181
|
"artifact.query": (input) => artifacts.query(input),
|
|
151
182
|
"artifact.show": (input) => artifacts.get(string(input, "id"), {
|
|
152
183
|
tree: input["tree"] === true,
|
|
@@ -169,9 +200,18 @@ function handlers(
|
|
|
169
200
|
depth: optionalNumber(input, "depth"),
|
|
170
201
|
maxNodes: optionalNumber(input, "max_nodes") ?? optionalNumber(input, "maxNodes"),
|
|
171
202
|
}),
|
|
172
|
-
"graph.status": (input) =>
|
|
173
|
-
|
|
174
|
-
|
|
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
|
+
},
|
|
214
|
+
"rules.injectable": () => listInjectableRules(artifacts, tasks.active()?.id)
|
|
175
215
|
.map(({ id, title, body, extra }) => ({ id, title, body, extra })),
|
|
176
216
|
"tasks.create": (input) => tasks.create({
|
|
177
217
|
title: string(input, "title"),
|
|
@@ -184,22 +224,27 @@ function handlers(
|
|
|
184
224
|
templateId: optionalString(input, "template_id") ?? optionalString(input, "templateId"),
|
|
185
225
|
parentId: optionalString(input, "parent_id") ?? optionalString(input, "parentId"),
|
|
186
226
|
dependsOn: (input["depends_on"] ?? input["dependsOn"]) as string[] | undefined,
|
|
187
|
-
}),
|
|
227
|
+
}, eventContext(input)),
|
|
188
228
|
"tasks.list": (input) => tasks.list(taskFilter(input)),
|
|
189
229
|
"tasks.graph": (input) => tasks.graph(taskFilter(input)),
|
|
190
230
|
"tasks.plan": (input) => projectTaskExecution(tasks.graph(taskFilter(input))),
|
|
191
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
|
+
}),
|
|
192
237
|
"tasks.active": () => tasks.active(),
|
|
193
238
|
"tasks.focus": (input) => tasks.focus(string(input, "id")),
|
|
194
|
-
"tasks.start": (input) => tasks.transition(string(input, "id"), "start"),
|
|
195
|
-
"tasks.submit": (input) => tasks.transition(string(input, "id"), "submit"),
|
|
196
|
-
"tasks.complete": (input) => tasks.completeAsync(string(input, "id")),
|
|
197
|
-
"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)),
|
|
198
243
|
"tasks.set_checklist": (input) => tasks.setChecklist(string(input, "id"), input["checklist"] as Checklist),
|
|
199
244
|
"tasks.context": () => taskContext(artifacts, tasks.active()?.id),
|
|
200
|
-
"tasks.reject": (input) => tasks.transition(string(input, "id"), "reject"),
|
|
201
|
-
"tasks.retry": (input) => tasks.transition(string(input, "id"), "retry"),
|
|
202
|
-
"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)),
|
|
203
248
|
"tasks.depend": (input) => tasks.depend(string(input, "id"), string(input, "dependency_id")),
|
|
204
249
|
"tasks.contain": (input) => tasks.contain(string(input, "parent_id"), string(input, "child_id")),
|
|
205
250
|
"docs.create": (input) => createDocument(artifacts, {
|
|
@@ -228,6 +273,7 @@ function handlers(
|
|
|
228
273
|
"skills.create": (input) => createSkill(artifacts, {
|
|
229
274
|
title: string(input, "title"), body: optionalString(input, "body"), trigger: optionalString(input, "trigger"),
|
|
230
275
|
steps: input["steps"] as string[] | undefined, tools: input["tools"] as string[] | undefined,
|
|
276
|
+
definition: input["definition"],
|
|
231
277
|
labels: input["labels"] as string[] | undefined, extra: input["extra"] as Record<string, unknown> | undefined,
|
|
232
278
|
}),
|
|
233
279
|
"skills.create_template": (input) => createArtifactTemplate(artifacts, {
|
|
@@ -237,6 +283,10 @@ function handlers(
|
|
|
237
283
|
"skills.list": (input) => listSkills(artifacts, taskFilter(input)),
|
|
238
284
|
"skills.show": (input) => showSkill(artifacts, string(input, "id")),
|
|
239
285
|
"skills.invoke": (input) => skillInvocation(artifacts, string(input, "id")),
|
|
286
|
+
"skills.run": (input) => instantiateSkillWorkflow(artifacts, string(input, "id"), {
|
|
287
|
+
runId: optionalString(input, "run_id") ?? optionalString(input, "runId"),
|
|
288
|
+
arguments: input["arguments"] as Record<string, unknown> | undefined,
|
|
289
|
+
}, { events, context: eventContextFor(input, "skill-run") }),
|
|
240
290
|
"skills.enable": (input) => transitionSkill(artifacts, string(input, "id"), "enable"),
|
|
241
291
|
"skills.disable": (input) => transitionSkill(artifacts, string(input, "id"), "disable"),
|
|
242
292
|
"skills.instantiate": (input) => instantiateTemplate(artifacts, string(input, "template_id"), normalizeCreateInput(input)),
|
|
@@ -248,8 +298,9 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
248
298
|
const artifacts = new SQLiteArtifactStore(db);
|
|
249
299
|
const gates = new SQLiteGateRunner(db);
|
|
250
300
|
const focus = new SQLiteTaskFocusStore(db);
|
|
251
|
-
const
|
|
252
|
-
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));
|
|
253
304
|
const state = (): SchemaState => {
|
|
254
305
|
const current = schemaVersion(db);
|
|
255
306
|
return { current, required: SQLITE_SCHEMA_VERSION, migrationRequired: current !== SQLITE_SCHEMA_VERSION };
|
|
@@ -261,7 +312,7 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
261
312
|
const handler = registry[operation as OperationName];
|
|
262
313
|
if (!handler) throw new UnknownOperationError(`unknown operation "${operation}"`);
|
|
263
314
|
if (operation !== "system.migrate" && state().migrationRequired) {
|
|
264
|
-
throw new MigrationRequiredError("database migration required; run `papyrus migrate task-
|
|
315
|
+
throw new MigrationRequiredError("database migration required; run `papyrus migrate task-history`");
|
|
265
316
|
}
|
|
266
317
|
return handler(input);
|
|
267
318
|
},
|
|
@@ -0,0 +1,220 @@
|
|
|
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 type { TaskEventContext } from "./domain/task-event.ts";
|
|
13
|
+
import type { TaskEventStore } from "./ports/task-event-store.ts";
|
|
14
|
+
import { requireAtomicArtifactStore } from "./ports/atomic-artifact-store.ts";
|
|
15
|
+
import { projectTaskExecution, type TaskExecutionPlan } from "./task-execution.ts";
|
|
16
|
+
import type { TaskGraph, TaskNode, TaskStatus } from "./task-service.ts";
|
|
17
|
+
|
|
18
|
+
const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
19
|
+
const EXACT_PLACEHOLDER_PATTERN = /^{{\s*([A-Za-z][A-Za-z0-9_-]{0,63})\s*}}$/;
|
|
20
|
+
const PLACEHOLDER_PATTERN = /{{\s*([A-Za-z][A-Za-z0-9_-]{0,63})\s*}}/g;
|
|
21
|
+
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
22
|
+
|
|
23
|
+
export interface InstantiateSkillWorkflowInput {
|
|
24
|
+
runId?: string;
|
|
25
|
+
arguments?: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SkillWorkflowRunResult {
|
|
29
|
+
skillId: string;
|
|
30
|
+
runId: string;
|
|
31
|
+
arguments: Record<string, SkillArgumentValue>;
|
|
32
|
+
created: {
|
|
33
|
+
docs: string[];
|
|
34
|
+
rules: string[];
|
|
35
|
+
tasks: string[];
|
|
36
|
+
};
|
|
37
|
+
rootTaskIds: string[];
|
|
38
|
+
execution: TaskExecutionPlan;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function requireWorkflowSkill(artifacts: ArtifactStore, skillId: string): { skill: Artifact; definition: SkillDefinition } {
|
|
42
|
+
const skill = artifacts.get(skillId);
|
|
43
|
+
if (!skill) throw new Error(`skill artifact "${skillId}" not found`);
|
|
44
|
+
if (skill.kind !== "skill" || skill.subtype !== "workflow") {
|
|
45
|
+
throw new Error(`artifact "${skillId}" is not a workflow Skill`);
|
|
46
|
+
}
|
|
47
|
+
if (skill.status !== "active") throw new Error(`cannot run workflow Skill from ${skill.status}`);
|
|
48
|
+
return { skill, definition: validateSkillDefinition(skill.extra["definition"]) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeRunId(skillId: string, requested: string | undefined): string {
|
|
52
|
+
const runId = requested ?? `${skillId.slice(0, 40)}-${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
|
53
|
+
if (runId.length > SKILL_RUN_ID_MAX_LENGTH || !RUN_ID_PATTERN.test(runId)) {
|
|
54
|
+
throw new Error(`skill run id must match ${RUN_ID_PATTERN} and contain at most ${SKILL_RUN_ID_MAX_LENGTH} characters`);
|
|
55
|
+
}
|
|
56
|
+
return runId;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function renderValue(value: unknown, arguments_: Record<string, SkillArgumentValue>): unknown {
|
|
60
|
+
if (typeof value === "string") {
|
|
61
|
+
const exact = value.match(EXACT_PLACEHOLDER_PATTERN);
|
|
62
|
+
if (exact) {
|
|
63
|
+
const name = exact[1]!;
|
|
64
|
+
if (!(name in arguments_)) throw new Error(`skill input placeholder "${name}" has no argument value`);
|
|
65
|
+
return arguments_[name]!;
|
|
66
|
+
}
|
|
67
|
+
return value.replace(PLACEHOLDER_PATTERN, (_placeholder, name: string) => {
|
|
68
|
+
if (!(name in arguments_)) throw new Error(`skill input placeholder "${name}" has no argument value`);
|
|
69
|
+
return String(arguments_[name]!);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (Array.isArray(value)) return value.map((entry) => renderValue(entry, arguments_));
|
|
73
|
+
if (typeof value !== "object" || value === null) return value;
|
|
74
|
+
const rendered: Record<string, unknown> = {};
|
|
75
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
76
|
+
if (UNSAFE_KEYS.has(key)) throw new Error(`unsafe skill blueprint key "${key}"`);
|
|
77
|
+
rendered[key] = renderValue(entry, arguments_);
|
|
78
|
+
}
|
|
79
|
+
return rendered;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function renderDefinition(definition: SkillDefinition, arguments_: Record<string, SkillArgumentValue>): SkillDefinition {
|
|
83
|
+
const rendered = renderValue(definition, arguments_) as SkillDefinition;
|
|
84
|
+
const bytes = new TextEncoder().encode(JSON.stringify(rendered)).byteLength;
|
|
85
|
+
if (bytes > SKILL_MAX_RENDERED_BYTES) throw new Error(`rendered skill workflow exceeds ${SKILL_MAX_RENDERED_BYTES} bytes`);
|
|
86
|
+
for (const task of rendered.blueprints.tasks) {
|
|
87
|
+
if (task.extra?.["checklist"] !== undefined) {
|
|
88
|
+
task.extra["checklist"] = validateChecklist(task.extra["checklist"]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return validateSkillDefinition(rendered);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function withRunLabel(labels: string[] | undefined, runId: string): string[] {
|
|
95
|
+
return [...new Set([...(labels ?? []), `skill-run:${runId}`])];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function executionGraph(tasks: Artifact[], definition: SkillDefinition, ids: Map<string, string>): TaskGraph {
|
|
99
|
+
const byRef = new Map(definition.blueprints.tasks.map((task) => [task.ref, task]));
|
|
100
|
+
const nodes: TaskNode[] = tasks.map((task) => {
|
|
101
|
+
const ref = task.extra["skillRun"] && typeof task.extra["skillRun"] === "object"
|
|
102
|
+
? (task.extra["skillRun"] as Record<string, unknown>)["ref"] as string
|
|
103
|
+
: "";
|
|
104
|
+
const blueprint = byRef.get(ref)!;
|
|
105
|
+
return {
|
|
106
|
+
task,
|
|
107
|
+
active: false,
|
|
108
|
+
parentIds: blueprint.parent ? [ids.get(blueprint.parent)!] : [],
|
|
109
|
+
childIds: definition.blueprints.tasks.filter((candidate) => candidate.parent === ref).map((candidate) => ids.get(candidate.ref)!),
|
|
110
|
+
dependencyIds: (blueprint.dependsOn ?? []).map((dependency) => ids.get(dependency)!),
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
return { nodes, rootIds: nodes.filter((node) => node.parentIds.length === 0).map((node) => node.task.id) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function instantiateSkillWorkflow(
|
|
117
|
+
artifacts: ArtifactStore,
|
|
118
|
+
skillId: string,
|
|
119
|
+
input: InstantiateSkillWorkflowInput = {},
|
|
120
|
+
history?: { events: TaskEventStore; context?: TaskEventContext },
|
|
121
|
+
): SkillWorkflowRunResult {
|
|
122
|
+
const { definition } = requireWorkflowSkill(artifacts, skillId);
|
|
123
|
+
const arguments_ = resolveSkillArguments(definition, input.arguments);
|
|
124
|
+
const rendered = renderDefinition(definition, arguments_);
|
|
125
|
+
const runId = normalizeRunId(skillId, input.runId);
|
|
126
|
+
const refs = [
|
|
127
|
+
...rendered.blueprints.docs.map(({ ref }) => ref),
|
|
128
|
+
...rendered.blueprints.rules.map(({ ref }) => ref),
|
|
129
|
+
...rendered.blueprints.tasks.map(({ ref }) => ref),
|
|
130
|
+
];
|
|
131
|
+
const ids = new Map(refs.map((ref) => [ref, `${runId}-${ref}`]));
|
|
132
|
+
const taskIds = rendered.blueprints.tasks.map(({ ref }) => ids.get(ref)!);
|
|
133
|
+
const rootTaskIds = rendered.blueprints.tasks
|
|
134
|
+
.filter((task) => (task.dependsOn?.length ?? 0) === 0)
|
|
135
|
+
.map((task) => ids.get(task.ref)!);
|
|
136
|
+
const relationshipCount = rendered.links.length
|
|
137
|
+
+ rendered.blueprints.tasks.reduce((count, task) => count + (task.dependsOn?.length ?? 0) + (task.parent ? 2 : 0), 0)
|
|
138
|
+
+ rootTaskIds.length;
|
|
139
|
+
if (relationshipCount > TASK_EXECUTION_MAX_EDGES) {
|
|
140
|
+
throw new Error(`skill workflow run exceeds ${TASK_EXECUTION_MAX_EDGES} relationships`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const atomic = requireAtomicArtifactStore(artifacts);
|
|
144
|
+
const persist = () => atomic.atomic(() => {
|
|
145
|
+
const docs = rendered.blueprints.docs.map((blueprint) => artifacts.create({
|
|
146
|
+
id: ids.get(blueprint.ref),
|
|
147
|
+
kind: "doc",
|
|
148
|
+
title: blueprint.title,
|
|
149
|
+
body: blueprint.body,
|
|
150
|
+
subtype: blueprint.subtype,
|
|
151
|
+
labels: withRunLabel(blueprint.labels, runId),
|
|
152
|
+
extra: { ...(blueprint.extra ?? {}), skillRun: { id: runId, skillId, ref: blueprint.ref } },
|
|
153
|
+
}));
|
|
154
|
+
const rules = rendered.blueprints.rules.map((blueprint) => artifacts.create({
|
|
155
|
+
id: ids.get(blueprint.ref),
|
|
156
|
+
kind: "rule",
|
|
157
|
+
title: blueprint.title,
|
|
158
|
+
body: blueprint.body,
|
|
159
|
+
labels: withRunLabel(blueprint.labels, runId),
|
|
160
|
+
extra: {
|
|
161
|
+
...(blueprint.extra ?? {}),
|
|
162
|
+
...(blueprint.condition ? { condition: blueprint.condition } : {}),
|
|
163
|
+
...(blueprint.action ? { action: blueprint.action } : {}),
|
|
164
|
+
...(blueprint.severity ? { severity: blueprint.severity } : {}),
|
|
165
|
+
skillRun: { id: runId, skillId, ref: blueprint.ref },
|
|
166
|
+
scope: { type: "skill-run", runId, taskIds },
|
|
167
|
+
},
|
|
168
|
+
}));
|
|
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
|
+
});
|
|
189
|
+
|
|
190
|
+
for (const blueprint of rendered.blueprints.tasks) {
|
|
191
|
+
const id = ids.get(blueprint.ref)!;
|
|
192
|
+
for (const dependency of blueprint.dependsOn ?? []) {
|
|
193
|
+
artifacts.link({ from: id, relation: "depends_on", to: ids.get(dependency)! });
|
|
194
|
+
}
|
|
195
|
+
if (blueprint.parent) {
|
|
196
|
+
const parentId = ids.get(blueprint.parent)!;
|
|
197
|
+
artifacts.link({ from: parentId, relation: "contains", to: id });
|
|
198
|
+
artifacts.link({ from: id, relation: "part_of", to: parentId });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
for (const link of rendered.links) {
|
|
202
|
+
artifacts.link({ from: ids.get(link.from)!, relation: link.relation, to: ids.get(link.to)! });
|
|
203
|
+
}
|
|
204
|
+
for (const rootTaskId of rootTaskIds) artifacts.link({ from: skillId, relation: "triggers", to: rootTaskId });
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
skillId,
|
|
208
|
+
runId,
|
|
209
|
+
arguments: arguments_,
|
|
210
|
+
created: {
|
|
211
|
+
docs: docs.map(({ id }) => id),
|
|
212
|
+
rules: rules.map(({ id }) => id),
|
|
213
|
+
tasks: tasks.map(({ id }) => id),
|
|
214
|
+
},
|
|
215
|
+
rootTaskIds,
|
|
216
|
+
execution: projectTaskExecution(executionGraph(tasks, rendered, ids)),
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
return history ? history.events.atomic(persist) : persist();
|
|
220
|
+
}
|