@danypops/papyrus 0.12.0 → 0.13.1

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.
@@ -1,11 +1,12 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { SKILL_MAX_RENDERED_BYTES, SKILL_RUN_ID_MAX_LENGTH, TASK_EXECUTION_MAX_EDGES } from "./constants.ts";
2
+ import { SKILL_MAX_RENDERED_BYTES, SKILL_RUN_ID_MAX_LENGTH, SKILL_WORKFLOW_MAX_NESTING_DEPTH, TASK_EXECUTION_MAX_EDGES } from "./constants.ts";
3
3
  import type { Artifact } from "./domain/artifact.ts";
4
4
  import { validateChecklist } from "./domain/checklist.ts";
5
5
  import {
6
6
  resolveSkillArguments,
7
7
  validateSkillDefinition,
8
8
  type SkillArgumentValue,
9
+ type SkillCallBlueprint,
9
10
  type SkillDefinition,
10
11
  } from "./domain/skill-definition.ts";
11
12
  import type { ArtifactStore } from "./ports/artifact-store.ts";
@@ -35,8 +36,12 @@ export interface SkillWorkflowRunResult {
35
36
  docs: string[];
36
37
  rules: string[];
37
38
  tasks: string[];
39
+ /** Nested workflow Skill runs this pipeline triggered as pipeline steps, in execution order. */
40
+ skillRuns: string[];
38
41
  };
42
+ /** Real starting points: for a nested skill-call root step, that nested run's own root tasks (recursively), not just "all its tasks". */
39
43
  rootTaskIds: string[];
44
+ /** Scoped to this definition's own directly-created tasks only -- nested runs' tasks are real, graph-linked, and visible via /tasks graph, but not folded into this projection. */
40
45
  execution: TaskExecutionPlan;
41
46
  }
42
47
 
@@ -115,12 +120,48 @@ function executionGraph(tasks: Artifact[], definition: SkillDefinition, ids: Map
115
120
  return { nodes, rootIds: nodes.filter((node) => node.parentIds.length === 0).map((node) => node.task.id) };
116
121
  }
117
122
 
123
+ type SkillWorkflowHistory = { events: TaskEventStore; scopes: TaskScopeStore; projectRoot: string; context?: TaskEventContext };
124
+
125
+ /**
126
+ * Public entry point: wraps one complete pipeline run (including every nested sub-pipeline
127
+ * it triggers) in exactly one atomic transaction. The recursive core (runWorkflowSteps) never
128
+ * opens its own atomic wrapper -- SQLite savepoint nesting (inTransaction in db.ts) would
129
+ * tolerate it, but wrapping once here keeps the atomicity story unambiguous: one skills.run
130
+ * call is one all-or-nothing graph mutation, however many nested skills it triggers.
131
+ */
118
132
  export function instantiateSkillWorkflow(
119
133
  artifacts: ArtifactStore,
120
134
  skillId: string,
121
135
  input: InstantiateSkillWorkflowInput = {},
122
- history?: { events: TaskEventStore; scopes: TaskScopeStore; projectRoot: string; context?: TaskEventContext },
136
+ history?: SkillWorkflowHistory,
123
137
  ): SkillWorkflowRunResult {
138
+ const run = () => runWorkflowSteps(artifacts, skillId, input, history, new Set(), 0);
139
+ if (history) return history.events.atomic(run);
140
+ return requireAtomicArtifactStore(artifacts).atomic(run);
141
+ }
142
+
143
+ /**
144
+ * The recursive pipeline core. A workflow Skill's `skills` blueprint entries are pipeline
145
+ * steps that trigger another workflow Skill's own run -- the Jenkins "downstream job" /
146
+ * Ansible "include_tasks" primitive. Nested runs execute BEFORE this level's dependsOn/parent
147
+ * edges are wired, since a step depending on a skill-call ref needs to know every task id
148
+ * that nested run actually produced (not knowable ahead of time -- it depends on the nested
149
+ * skill's own definition). `ancestorSkillIds` tracks the current call CHAIN (not a global
150
+ * ever-visited set): sibling skill-calls under the same parent are independent and may
151
+ * legitimately share a called skill; only a real cycle back to an ancestor is rejected.
152
+ */
153
+ function runWorkflowSteps(
154
+ artifacts: ArtifactStore,
155
+ skillId: string,
156
+ input: InstantiateSkillWorkflowInput,
157
+ history: SkillWorkflowHistory | undefined,
158
+ ancestorSkillIds: ReadonlySet<string>,
159
+ depth: number,
160
+ ): SkillWorkflowRunResult {
161
+ if (ancestorSkillIds.has(skillId)) throw new Error(`skill workflow nesting cycle includes "${skillId}"`);
162
+ if (depth > SKILL_WORKFLOW_MAX_NESTING_DEPTH) throw new Error(`skill workflow nesting exceeds ${SKILL_WORKFLOW_MAX_NESTING_DEPTH} levels`);
163
+ const nextAncestors = new Set([...ancestorSkillIds, skillId]);
164
+
124
165
  const { definition } = requireWorkflowSkill(artifacts, skillId);
125
166
  const projectRoot = history ? normalizeProjectRoot(history.projectRoot) : undefined;
126
167
  const arguments_ = resolveSkillArguments(definition, input.arguments);
@@ -130,97 +171,150 @@ export function instantiateSkillWorkflow(
130
171
  ...rendered.blueprints.docs.map(({ ref }) => ref),
131
172
  ...rendered.blueprints.rules.map(({ ref }) => ref),
132
173
  ...rendered.blueprints.tasks.map(({ ref }) => ref),
174
+ ...rendered.blueprints.skills.map(({ ref }) => ref),
133
175
  ];
134
176
  const ids = new Map(refs.map((ref) => [ref, `${runId}-${ref}`]));
135
177
  const taskIds = rendered.blueprints.tasks.map(({ ref }) => ids.get(ref)!);
136
- const rootTaskIds = rendered.blueprints.tasks
137
- .filter((task) => (task.dependsOn?.length ?? 0) === 0)
138
- .map((task) => ids.get(task.ref)!);
178
+ // A bound at THIS level's own blueprint size; nested runs are independently bounded the same
179
+ // way at their own level, and nesting depth is separately capped -- so total blast radius
180
+ // across a whole pipeline stays bounded on both dimensions even though a step's dependency
181
+ // on a skill-call ref can fan out to more edges than this per-level count captures exactly.
139
182
  const relationshipCount = rendered.links.length
140
183
  + rendered.blueprints.tasks.reduce((count, task) => count + (task.dependsOn?.length ?? 0) + (task.parent ? 2 : 0), 0)
141
- + rootTaskIds.length;
184
+ + rendered.blueprints.skills.reduce((count, call) => count + (call.dependsOn?.length ?? 0) + (call.parent ? 2 : 0), 0)
185
+ + rendered.blueprints.tasks.filter((task) => (task.dependsOn?.length ?? 0) === 0).length
186
+ + rendered.blueprints.skills.filter((call) => (call.dependsOn?.length ?? 0) === 0).length;
142
187
  if (relationshipCount > TASK_EXECUTION_MAX_EDGES) {
143
188
  throw new Error(`skill workflow run exceeds ${TASK_EXECUTION_MAX_EDGES} relationships`);
144
189
  }
145
190
 
146
- const atomic = requireAtomicArtifactStore(artifacts);
147
- const persist = () => atomic.atomic(() => {
148
- const docs = rendered.blueprints.docs.map((blueprint) => artifacts.create({
191
+ const docs = rendered.blueprints.docs.map((blueprint) => artifacts.create({
192
+ id: ids.get(blueprint.ref),
193
+ kind: "doc",
194
+ title: blueprint.title,
195
+ body: blueprint.body,
196
+ subtype: blueprint.subtype,
197
+ labels: withRunLabel(blueprint.labels, runId),
198
+ extra: { ...(blueprint.extra ?? {}), skillRun: { id: runId, skillId, ref: blueprint.ref } },
199
+ }));
200
+ const rules = rendered.blueprints.rules.map((blueprint) => artifacts.create({
201
+ id: ids.get(blueprint.ref),
202
+ kind: "rule",
203
+ title: blueprint.title,
204
+ body: blueprint.body,
205
+ labels: withRunLabel(blueprint.labels, runId),
206
+ extra: {
207
+ ...(blueprint.extra ?? {}),
208
+ ...(blueprint.condition ? { condition: blueprint.condition } : {}),
209
+ ...(blueprint.action ? { action: blueprint.action } : {}),
210
+ ...(blueprint.severity ? { severity: blueprint.severity } : {}),
211
+ skillRun: { id: runId, skillId, ref: blueprint.ref },
212
+ scope: { type: "skill-run", runId, taskIds },
213
+ },
214
+ }));
215
+ const tasks = rendered.blueprints.tasks.map((blueprint) => {
216
+ const task = artifacts.create({
149
217
  id: ids.get(blueprint.ref),
150
- kind: "doc",
218
+ kind: "task",
151
219
  title: blueprint.title,
152
220
  body: blueprint.body,
153
- subtype: blueprint.subtype,
154
221
  labels: withRunLabel(blueprint.labels, runId),
155
222
  extra: { ...(blueprint.extra ?? {}), skillRun: { id: runId, skillId, ref: blueprint.ref } },
156
- }));
157
- const rules = rendered.blueprints.rules.map((blueprint) => artifacts.create({
158
- id: ids.get(blueprint.ref),
159
- kind: "rule",
160
- title: blueprint.title,
161
- body: blueprint.body,
162
- labels: withRunLabel(blueprint.labels, runId),
163
- extra: {
164
- ...(blueprint.extra ?? {}),
165
- ...(blueprint.condition ? { condition: blueprint.condition } : {}),
166
- ...(blueprint.action ? { action: blueprint.action } : {}),
167
- ...(blueprint.severity ? { severity: blueprint.severity } : {}),
168
- skillRun: { id: runId, skillId, ref: blueprint.ref },
169
- scope: { type: "skill-run", runId, taskIds },
170
- },
171
- }));
172
- const tasks = rendered.blueprints.tasks.map((blueprint) => {
173
- const task = artifacts.create({
174
- id: ids.get(blueprint.ref),
175
- kind: "task",
176
- title: blueprint.title,
177
- body: blueprint.body,
178
- labels: withRunLabel(blueprint.labels, runId),
179
- extra: { ...(blueprint.extra ?? {}), skillRun: { id: runId, skillId, ref: blueprint.ref } },
180
- });
181
- if (history) {
182
- history.scopes.assign(task.id, projectRoot, "cwd");
183
- history.events.append({
184
- taskId: task.id,
185
- type: "created",
186
- actor: history.context?.actor ?? "system",
187
- source: history.context?.source ?? "skill-run",
188
- toStatus: task.status as TaskStatus,
189
- ...(history.context?.sessionId === undefined ? {} : { sessionId: history.context.sessionId }),
190
- ...(history.context?.reason === undefined ? {} : { reason: history.context.reason }),
191
- });
192
- }
193
- return task;
194
223
  });
224
+ if (history) {
225
+ history.scopes.assign(task.id, projectRoot, "cwd");
226
+ history.events.append({
227
+ taskId: task.id,
228
+ type: "created",
229
+ actor: history.context?.actor ?? "system",
230
+ source: history.context?.source ?? "skill-run",
231
+ toStatus: task.status as TaskStatus,
232
+ ...(history.context?.sessionId === undefined ? {} : { sessionId: history.context.sessionId }),
233
+ ...(history.context?.reason === undefined ? {} : { reason: history.context.reason }),
234
+ });
235
+ }
236
+ return task;
237
+ });
195
238
 
196
- for (const blueprint of rendered.blueprints.tasks) {
197
- const id = ids.get(blueprint.ref)!;
198
- for (const dependency of blueprint.dependsOn ?? []) {
199
- artifacts.link({ from: id, relation: "depends_on", to: ids.get(dependency)! });
200
- }
201
- if (blueprint.parent) {
202
- const parentId = ids.get(blueprint.parent)!;
203
- artifacts.link({ from: parentId, relation: "contains", to: id });
204
- artifacts.link({ from: id, relation: "part_of", to: parentId });
239
+ // Nested pipeline steps run before edge-wiring: dependents need to know what tasks each
240
+ // nested run actually produced. stepTaskIds/stepRootTaskIds map EVERY step ref (task or
241
+ // skill-call) to the task id(s) it resolves to, so dependsOn/parent wiring below treats
242
+ // both kinds of step uniformly.
243
+ const nestedRuns: SkillWorkflowRunResult[] = [];
244
+ const stepTaskIds = new Map<string, string[]>(tasks.map((task, index) => [rendered.blueprints.tasks[index]!.ref, [task.id]]));
245
+ const stepRootTaskIds = new Map<string, string[]>(
246
+ tasks.map((task, index) => [rendered.blueprints.tasks[index]!.ref, (rendered.blueprints.tasks[index]!.dependsOn?.length ?? 0) === 0 ? [task.id] : []]),
247
+ );
248
+ for (const call of rendered.blueprints.skills as SkillCallBlueprint[]) {
249
+ const nested = runWorkflowSteps(
250
+ artifacts,
251
+ call.skillId,
252
+ { runId: `${runId}-${call.ref}`, arguments: call.arguments },
253
+ history,
254
+ nextAncestors,
255
+ depth + 1,
256
+ );
257
+ nestedRuns.push(nested);
258
+ stepTaskIds.set(call.ref, nested.created.tasks);
259
+ stepRootTaskIds.set(call.ref, nested.rootTaskIds);
260
+ }
261
+
262
+ for (const blueprint of rendered.blueprints.tasks) {
263
+ const id = ids.get(blueprint.ref)!;
264
+ for (const dependency of blueprint.dependsOn ?? []) {
265
+ for (const dependencyId of stepTaskIds.get(dependency) ?? []) artifacts.link({ from: id, relation: "depends_on", to: dependencyId });
266
+ }
267
+ if (blueprint.parent) {
268
+ const parentId = ids.get(blueprint.parent)!;
269
+ artifacts.link({ from: parentId, relation: "contains", to: id });
270
+ artifacts.link({ from: id, relation: "part_of", to: parentId });
271
+ }
272
+ }
273
+ for (const call of rendered.blueprints.skills as SkillCallBlueprint[]) {
274
+ const stepTaskIdsForCall = stepTaskIds.get(call.ref) ?? [];
275
+ for (const dependency of call.dependsOn ?? []) {
276
+ for (const dependencyId of stepTaskIds.get(dependency) ?? []) {
277
+ for (const taskId of stepTaskIdsForCall) artifacts.link({ from: taskId, relation: "depends_on", to: dependencyId });
205
278
  }
206
279
  }
207
- for (const link of rendered.links) {
208
- artifacts.link({ from: ids.get(link.from)!, relation: link.relation, to: ids.get(link.to)! });
280
+ if (call.parent) {
281
+ const parentId = ids.get(call.parent)!;
282
+ for (const rootTaskId of stepRootTaskIds.get(call.ref) ?? []) {
283
+ artifacts.link({ from: parentId, relation: "contains", to: rootTaskId });
284
+ artifacts.link({ from: rootTaskId, relation: "part_of", to: parentId });
285
+ }
209
286
  }
210
- for (const rootTaskId of rootTaskIds) artifacts.link({ from: skillId, relation: "triggers", to: rootTaskId });
287
+ }
288
+ for (const link of rendered.links) {
289
+ const fromIds = stepTaskIds.get(link.from) ?? [ids.get(link.from)!];
290
+ const toIds = stepTaskIds.get(link.to) ?? [ids.get(link.to)!];
291
+ for (const from of fromIds) for (const to of toIds) artifacts.link({ from, relation: link.relation, to });
292
+ }
211
293
 
212
- return {
213
- skillId,
214
- runId,
215
- arguments: arguments_,
216
- created: {
217
- docs: docs.map(({ id }) => id),
218
- rules: rules.map(({ id }) => id),
219
- tasks: tasks.map(({ id }) => id),
220
- },
221
- rootTaskIds,
222
- execution: projectTaskExecution(executionGraph(tasks, rendered, ids)),
223
- };
224
- });
225
- return history ? history.events.atomic(persist) : persist();
294
+ const rootTaskIds = [
295
+ ...rendered.blueprints.tasks.filter((task) => (task.dependsOn?.length ?? 0) === 0).map((task) => ids.get(task.ref)!),
296
+ ...(rendered.blueprints.skills as SkillCallBlueprint[])
297
+ .filter((call) => (call.dependsOn?.length ?? 0) === 0)
298
+ .flatMap((call) => stepRootTaskIds.get(call.ref) ?? []),
299
+ ];
300
+ for (const task of rendered.blueprints.tasks) {
301
+ if ((task.dependsOn?.length ?? 0) === 0) artifacts.link({ from: skillId, relation: "triggers", to: ids.get(task.ref)! });
302
+ }
303
+ for (const call of rendered.blueprints.skills as SkillCallBlueprint[]) {
304
+ if ((call.dependsOn?.length ?? 0) === 0) artifacts.link({ from: skillId, relation: "triggers", to: call.skillId });
305
+ }
306
+
307
+ return {
308
+ skillId,
309
+ runId,
310
+ arguments: arguments_,
311
+ created: {
312
+ docs: [...docs.map(({ id }) => id), ...nestedRuns.flatMap((run) => run.created.docs)],
313
+ rules: [...rules.map(({ id }) => id), ...nestedRuns.flatMap((run) => run.created.rules)],
314
+ tasks: [...tasks.map(({ id }) => id), ...nestedRuns.flatMap((run) => run.created.tasks)],
315
+ skillRuns: [...nestedRuns.map((run) => run.runId), ...nestedRuns.flatMap((run) => run.created.skillRuns)],
316
+ },
317
+ rootTaskIds,
318
+ execution: projectTaskExecution(executionGraph(tasks, rendered, ids)),
319
+ };
226
320
  }