@danypops/papyrus 0.41.0 → 0.42.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.
@@ -3,13 +3,14 @@ import { SKILL_MAX_RENDERED_BYTES, SKILL_RUN_ID_MAX_LENGTH, SKILL_WORKFLOW_MAX_N
3
3
  import type { Artifact } from "./domain/artifact.ts";
4
4
  import { validateChecklist } from "./domain/checklist.ts";
5
5
  import {
6
- resolveSkillArguments,
7
- validateSkillDefinition,
8
- type SkillArgumentValue,
9
- type SkillCallBlueprint,
10
- type SkillDefinition,
11
- } from "./domain/skill-definition.ts";
6
+ resolveBlueprintArguments,
7
+ validateBlueprintDefinition,
8
+ type BlueprintArgumentValue,
9
+ type CallBlueprint,
10
+ type BlueprintDefinition,
11
+ } from "./domain/blueprint-definition.ts";
12
12
  import type { ArtifactStore } from "./ports/artifact-store.ts";
13
+ import { compilePlaybookDefinition, type PlaybookExternalLink } from "./playbook-definition.ts";
13
14
  import type { TaskEventContext } from "./domain/task-event.ts";
14
15
  import type { TaskEventStore } from "./ports/task-event-store.ts";
15
16
  import type { TaskScopeStore } from "./ports/task-scope-store.ts";
@@ -34,7 +35,7 @@ export interface InstantiateSkillWorkflowInput {
34
35
  * Identifies who owns a materialized run for tagging purposes: which artifact gets the
35
36
  * `triggers` edges to its root tasks, which extra-bag key records run lineage on each created
36
37
  * artifact, and which label prefix scopes them. Defaults used by instantiateSkillWorkflow
37
- * (ownerId: the skill's own id, extraKey: "skillRun", labelPrefix: "skill-run") are unchanged
38
+ * (ownerId: the target's own id, extraKey: "skillRun", labelPrefix: "skill-run") are unchanged
38
39
  * from before this was made pluggable -- a Playbook-compiled run supplies its own (playbook id,
39
40
  * "playbookRun", "playbook-run") instead, the only thing that actually differs between the two.
40
41
  */
@@ -47,15 +48,15 @@ export interface WorkflowLineage {
47
48
  export interface WorkflowRunResult {
48
49
  skillId: string;
49
50
  runId: string;
50
- arguments: Record<string, SkillArgumentValue>;
51
+ arguments: Record<string, BlueprintArgumentValue>;
51
52
  created: {
52
53
  docs: string[];
53
54
  rules: string[];
54
55
  tasks: string[];
55
- /** Nested workflow Skill runs this pipeline triggered as pipeline steps, in execution order. */
56
+ /** Nested workflow-definition runs this pipeline triggered as pipeline steps, in execution order. */
56
57
  skillRuns: string[];
57
58
  };
58
- /** Real starting points: for a nested skill-call root step, that nested run's own root tasks (recursively), not just "all its tasks". */
59
+ /** Real starting points: for a nested call root step, that nested run's own root tasks (recursively), not just "all its tasks". */
59
60
  rootTaskIds: string[];
60
61
  /** Resolved from input.focusRef when supplied -- the one real task id a caller (e.g. Playbook invocation) should focus, undefined when focusRef was not requested or names an unknown ref. */
61
62
  entryTaskId?: string;
@@ -63,34 +64,39 @@ export interface WorkflowRunResult {
63
64
  execution: TaskExecutionPlan;
64
65
  }
65
66
 
66
- function requireWorkflowSkill(artifacts: ArtifactStore, skillId: string): { skill: Artifact; definition: SkillDefinition } {
67
+ /**
68
+ * A definition-shaped target: kind=playbook (Skill-the-kind is retired; every remaining
69
+ * definition-holding row -- migrated legacy or freshly constructed -- lives under kind=playbook
70
+ * now) with subtype=workflow, distinguishing it from an ordinary steps/trigger-shaped Playbook.
71
+ */
72
+ function requireWorkflowSkill(artifacts: ArtifactStore, skillId: string): { skill: Artifact; definition: BlueprintDefinition } {
67
73
  const skill = artifacts.get(skillId);
68
- if (!skill) throw new Error(`skill artifact "${skillId}" not found`);
69
- if (skill.kind !== "skill" || skill.subtype !== "workflow") {
70
- throw new Error(`artifact "${skillId}" is not a workflow Skill`);
74
+ if (!skill) throw new Error(`playbook artifact "${skillId}" not found`);
75
+ if (skill.kind !== "playbook" || skill.subtype !== "workflow") {
76
+ throw new Error(`artifact "${skillId}" is not a workflow-definition playbook`);
71
77
  }
72
- if (skill.status !== "active") throw new Error(`cannot run workflow Skill from ${skill.status}`);
73
- return { skill, definition: validateSkillDefinition(skill.extra["definition"]) };
78
+ if (skill.status !== "active") throw new Error(`cannot run workflow playbook from ${skill.status}`);
79
+ return { skill, definition: validateBlueprintDefinition(skill.extra["definition"]) };
74
80
  }
75
81
 
76
82
  function normalizeRunId(skillId: string, requested: string | undefined): string {
77
83
  const runId = requested ?? `${skillId.slice(0, 40)}-${randomUUID().replaceAll("-", "").slice(0, 12)}`;
78
84
  if (runId.length > SKILL_RUN_ID_MAX_LENGTH || !RUN_ID_PATTERN.test(runId)) {
79
- throw new Error(`skill run id must match ${RUN_ID_PATTERN} and contain at most ${SKILL_RUN_ID_MAX_LENGTH} characters`);
85
+ throw new Error(`run id must match ${RUN_ID_PATTERN} and contain at most ${SKILL_RUN_ID_MAX_LENGTH} characters`);
80
86
  }
81
87
  return runId;
82
88
  }
83
89
 
84
- function renderValue(value: unknown, arguments_: Record<string, SkillArgumentValue>): unknown {
90
+ function renderValue(value: unknown, arguments_: Record<string, BlueprintArgumentValue>): unknown {
85
91
  if (typeof value === "string") {
86
92
  const exact = value.match(EXACT_PLACEHOLDER_PATTERN);
87
93
  if (exact) {
88
94
  const name = exact[1]!;
89
- if (!(name in arguments_)) throw new Error(`skill input placeholder "${name}" has no argument value`);
95
+ if (!(name in arguments_)) throw new Error(`input placeholder "${name}" has no argument value`);
90
96
  return arguments_[name]!;
91
97
  }
92
98
  return value.replace(PLACEHOLDER_PATTERN, (_placeholder, name: string) => {
93
- if (!(name in arguments_)) throw new Error(`skill input placeholder "${name}" has no argument value`);
99
+ if (!(name in arguments_)) throw new Error(`input placeholder "${name}" has no argument value`);
94
100
  return String(arguments_[name]!);
95
101
  });
96
102
  }
@@ -98,29 +104,29 @@ function renderValue(value: unknown, arguments_: Record<string, SkillArgumentVal
98
104
  if (typeof value !== "object" || value === null) return value;
99
105
  const rendered: Record<string, unknown> = {};
100
106
  for (const [key, entry] of Object.entries(value)) {
101
- if (UNSAFE_KEYS.has(key)) throw new Error(`unsafe skill blueprint key "${key}"`);
107
+ if (UNSAFE_KEYS.has(key)) throw new Error(`unsafe blueprint key "${key}"`);
102
108
  rendered[key] = renderValue(entry, arguments_);
103
109
  }
104
110
  return rendered;
105
111
  }
106
112
 
107
- function renderDefinition(definition: SkillDefinition, arguments_: Record<string, SkillArgumentValue>): SkillDefinition {
108
- const rendered = renderValue(definition, arguments_) as SkillDefinition;
113
+ function renderDefinition(definition: BlueprintDefinition, arguments_: Record<string, BlueprintArgumentValue>): BlueprintDefinition {
114
+ const rendered = renderValue(definition, arguments_) as BlueprintDefinition;
109
115
  const bytes = new TextEncoder().encode(JSON.stringify(rendered)).byteLength;
110
- if (bytes > SKILL_MAX_RENDERED_BYTES) throw new Error(`rendered skill workflow exceeds ${SKILL_MAX_RENDERED_BYTES} bytes`);
116
+ if (bytes > SKILL_MAX_RENDERED_BYTES) throw new Error(`rendered workflow exceeds ${SKILL_MAX_RENDERED_BYTES} bytes`);
111
117
  for (const task of rendered.blueprints.tasks) {
112
118
  if (task.extra?.["checklist"] !== undefined) {
113
119
  task.extra["checklist"] = validateChecklist(task.extra["checklist"]);
114
120
  }
115
121
  }
116
- return validateSkillDefinition(rendered);
122
+ return validateBlueprintDefinition(rendered);
117
123
  }
118
124
 
119
125
  function withRunLabel(labels: string[] | undefined, labelPrefix: string, runId: string): string[] {
120
126
  return [...new Set([...(labels ?? []), `${labelPrefix}:${runId}`])];
121
127
  }
122
128
 
123
- function executionGraph(tasks: Artifact[], definition: SkillDefinition, ids: Map<string, string>, extraKey: string): TaskGraph {
129
+ function executionGraph(tasks: Artifact[], definition: BlueprintDefinition, ids: Map<string, string>, extraKey: string): TaskGraph {
124
130
  const byRef = new Map(definition.blueprints.tasks.map((task) => [task.ref, task]));
125
131
  const nodes: TaskNode[] = tasks.map((task) => {
126
132
  const ref = task.extra[extraKey] && typeof task.extra[extraKey] === "object"
@@ -138,15 +144,15 @@ function executionGraph(tasks: Artifact[], definition: SkillDefinition, ids: Map
138
144
  return { nodes, rootIds: nodes.filter((node) => node.parentIds.length === 0).map((node) => node.task.id) };
139
145
  }
140
146
 
141
- /** projectRoot is optional -- skills.run always supplies one (workflow Skill runs are always project-scoped today), while a Playbook invocation may legitimately be ad hoc/cross-project (e.g. a lab-deploy playbook not tied to any one repo), landing its tasks in the same "unscoped" bucket Tasks.create already supports for a caller that omits projectRoot entirely. */
147
+ /** projectRoot is optional -- skills.run always supplies one (workflow-definition runs are always project-scoped today), while a Playbook invocation may legitimately be ad hoc/cross-project (e.g. a lab-deploy playbook not tied to any one repo), landing its tasks in the same "unscoped" bucket Tasks.create already supports for a caller that omits projectRoot entirely. */
142
148
  export type WorkflowRunHistory = { events: TaskEventStore; scopes: TaskScopeStore; projectRoot?: string; context?: TaskEventContext };
143
149
 
144
150
  /**
145
151
  * Public entry point: wraps one complete pipeline run (including every nested sub-pipeline
146
152
  * it triggers) in exactly one atomic transaction. The recursive core (runWorkflowSteps) never
147
153
  * opens its own atomic wrapper -- SQLite savepoint nesting (inTransaction in db.ts) would
148
- * tolerate it, but wrapping once here keeps the atomicity story unambiguous: one skills.run
149
- * call is one all-or-nothing graph mutation, however many nested skills it triggers.
154
+ * tolerate it, but wrapping once here keeps the atomicity story unambiguous: one run call is
155
+ * one all-or-nothing graph mutation, however many nested targets it triggers.
150
156
  */
151
157
  export function instantiateSkillWorkflow(
152
158
  artifacts: ArtifactStore,
@@ -159,31 +165,80 @@ export function instantiateSkillWorkflow(
159
165
  return requireAtomicArtifactStore(artifacts).atomic(run);
160
166
  }
161
167
 
168
+ /** Reads each created task's own lineage.ref tag back off the store -- resolves a compiled Playbook's blueprint refs back to real task ids after materialization, without threading an extra ref-to-id map out of materializeWorkflowDefinition's own return shape. Shared by top-level Playbook invocation (playbook-execution.ts) and a nested Playbook pipeline-call step alike. */
169
+ export function resolveRefToTaskId(artifacts: ArtifactStore, taskIds: string[], extraKey: string): Map<string, string> {
170
+ const map = new Map<string, string>();
171
+ for (const taskId of taskIds) {
172
+ const lineage = artifacts.get(taskId)?.extra[extraKey];
173
+ if (typeof lineage !== "object" || lineage === null || Array.isArray(lineage)) continue;
174
+ const ref = (lineage as Record<string, unknown>)["ref"];
175
+ if (typeof ref === "string") map.set(ref, taskId);
176
+ }
177
+ return map;
178
+ }
179
+
180
+ /** Applies a compiled Playbook's external links (a Rule that `gates` it, a Doc it `references`, etc.) once its blueprint refs have resolved to real task ids -- shared by top-level Playbook invocation and a nested Playbook pipeline-call step alike. */
181
+ export function applyPlaybookExternalLinks(artifacts: ArtifactStore, externalLinks: PlaybookExternalLink[], refToTaskId: Map<string, string>): void {
182
+ for (const link of externalLinks) {
183
+ const taskId = refToTaskId.get(link.rootRef);
184
+ if (!taskId) continue; // defensive -- every rootRef the compiler emits is always materialized
185
+ if (link.ownerIsFrom) artifacts.link({ from: taskId, relation: link.relation, to: link.otherArtifactId });
186
+ else artifacts.link({ from: link.otherArtifactId, relation: link.relation, to: taskId });
187
+ }
188
+ }
189
+
162
190
  /**
163
- * The recursive pipeline core. A workflow Skill's `skills` blueprint entries are pipeline
164
- * steps that trigger another workflow Skill's own run -- the Jenkins "downstream job" /
165
- * Ansible "include_tasks" primitive. Nested runs execute BEFORE this level's dependsOn/parent
166
- * edges are wired, since a step depending on a skill-call ref needs to know every task id
167
- * that nested run actually produced (not knowable ahead of time -- it depends on the nested
168
- * skill's own definition). `ancestorSkillIds` tracks the current call CHAIN (not a global
169
- * ever-visited set): sibling skill-calls under the same parent are independent and may
170
- * legitimately share a called skill; only a real cycle back to an ancestor is rejected.
191
+ * The recursive pipeline core. A `skills` blueprint entry's target can be either a
192
+ * workflow-definition Playbook (an already-persisted, versioned JSON blueprint) or an ordinary
193
+ * steps/trigger-shaped Playbook (its steps/composition tree compiled fresh, right here, the
194
+ * same way a top-level Playbook invocation does) -- the Jenkins "downstream job" / Ansible
195
+ * "include_tasks" primitive either way, dispatched purely by the target artifact's own subtype.
196
+ * Nested runs execute BEFORE this level's dependsOn/parent edges are wired, since a step
197
+ * depending on a call ref needs to know every task id that nested run actually produced (not
198
+ * knowable ahead of time -- it depends on the nested target's own definition). `ancestorIds`
199
+ * tracks the current call CHAIN (not a global ever-visited set): sibling calls under the same
200
+ * parent are independent and may legitimately share a called target; only a real cycle back to
201
+ * an ancestor is rejected. This is a SEPARATE cycle/depth check from a Playbook's own
202
+ * contains/depends_on composition tree (playbook-definition.ts's compileNode) -- a cycle
203
+ * threading through BOTH graphs at once (Playbook composition -> a call step -> back into that
204
+ * same Playbook's composition) is not cross-checked between the two, but each graph's own
205
+ * independent depth cap still bounds it; it fails with a nesting-depth error rather than a
206
+ * precise cycle message, not an infinite loop.
171
207
  */
172
208
  function runWorkflowSteps(
173
209
  artifacts: ArtifactStore,
174
- skillId: string,
210
+ targetId: string,
175
211
  input: InstantiateSkillWorkflowInput,
176
212
  history: WorkflowRunHistory | undefined,
177
- ancestorSkillIds: ReadonlySet<string>,
213
+ ancestorIds: ReadonlySet<string>,
178
214
  depth: number,
179
215
  ): WorkflowRunResult {
180
- if (ancestorSkillIds.has(skillId)) throw new Error(`skill workflow nesting cycle includes "${skillId}"`);
181
- if (depth > SKILL_WORKFLOW_MAX_NESTING_DEPTH) throw new Error(`skill workflow nesting exceeds ${SKILL_WORKFLOW_MAX_NESTING_DEPTH} levels`);
182
- const nextAncestors = new Set([...ancestorSkillIds, skillId]);
183
- const { definition } = requireWorkflowSkill(artifacts, skillId);
216
+ if (ancestorIds.has(targetId)) throw new Error(`workflow nesting cycle includes "${targetId}"`);
217
+ if (depth > SKILL_WORKFLOW_MAX_NESTING_DEPTH) throw new Error(`workflow nesting exceeds ${SKILL_WORKFLOW_MAX_NESTING_DEPTH} levels`);
218
+ const nextAncestors = new Set([...ancestorIds, targetId]);
219
+ const target = artifacts.get(targetId);
220
+ // subtype=workflow is the legacy definition-shaped case (raw extra.definition, no
221
+ // extra.steps) -- compilePlaybookDefinition assumes an ordinary steps/trigger-shaped
222
+ // Playbook and must not see it; it falls through to requireWorkflowSkill below instead.
223
+ if (target?.kind === "playbook" && target.subtype !== "workflow") {
224
+ const compiled = compilePlaybookDefinition(artifacts, targetId);
225
+ const result = materializeWorkflowDefinition(
226
+ artifacts,
227
+ { ownerId: targetId, extraKey: "playbookRun", labelPrefix: "playbook-run" },
228
+ compiled.definition,
229
+ { ...input, focusRef: compiled.entryRef },
230
+ history,
231
+ nextAncestors,
232
+ depth,
233
+ );
234
+ const refToTaskId = resolveRefToTaskId(artifacts, result.created.tasks, "playbookRun");
235
+ applyPlaybookExternalLinks(artifacts, compiled.externalLinks, refToTaskId);
236
+ return result;
237
+ }
238
+ const { definition } = requireWorkflowSkill(artifacts, targetId);
184
239
  return materializeWorkflowDefinition(
185
240
  artifacts,
186
- { ownerId: skillId, extraKey: "skillRun", labelPrefix: "skill-run" },
241
+ { ownerId: targetId, extraKey: "skillRun", labelPrefix: "skill-run" },
187
242
  definition,
188
243
  input,
189
244
  history,
@@ -193,23 +248,23 @@ function runWorkflowSteps(
193
248
  }
194
249
 
195
250
  /**
196
- * The definition-materialization core, shared by workflow Skills (instantiateSkillWorkflow,
197
- * via runWorkflowSteps above) and Playbook invocation (playbook-execution.ts): given an
198
- * ALREADY-RESOLVED SkillDefinition -- fetched from a persisted Skill artifact for the Skill
199
- * path, compiled in-memory from a Playbook's steps/trigger/arguments and its contains/
200
- * depends_on composition tree for the Playbook path -- creates every blueprint artifact,
201
- * wires dependsOn/parent/links, recurses into nested skill-call pipeline steps (a no-op for a
202
- * Playbook-compiled definition, which never populates blueprints.skills), and tags every
203
- * created artifact and the run's containing labels via `lineage` rather than a hardcoded
204
- * "skillRun"/"skill-run" shape -- the only thing that differs between the two callers.
205
- * `ancestorSkillIds`/`depth` are the same cycle/nesting-depth tracking runWorkflowSteps already
206
- * enforced before this was extracted; a Playbook caller with no nested skill-calls to recurse
207
- * into passes an empty set and depth 0 and never revisits this function itself.
251
+ * The definition-materialization core, shared by workflow-definition targets
252
+ * (instantiateSkillWorkflow, via runWorkflowSteps above) and Playbook invocation
253
+ * (playbook-execution.ts): given an ALREADY-RESOLVED BlueprintDefinition -- fetched from a
254
+ * persisted workflow-definition artifact for that path, compiled in-memory from a Playbook's
255
+ * steps/trigger/arguments and its contains/depends_on composition tree for the Playbook path --
256
+ * creates every blueprint artifact, wires dependsOn/parent/links, recurses into nested call
257
+ * pipeline steps (a no-op for a Playbook-compiled definition, which never populates
258
+ * blueprints.skills), and tags every created artifact and the run's containing labels via
259
+ * `lineage` rather than a hardcoded "skillRun"/"skill-run" shape -- the only thing that differs
260
+ * between the two callers. `ancestorSkillIds`/`depth` are the same cycle/nesting-depth tracking
261
+ * runWorkflowSteps already enforced before this was extracted; a Playbook caller with no nested
262
+ * calls to recurse into passes an empty set and depth 0 and never revisits this function itself.
208
263
  */
209
264
  export function materializeWorkflowDefinition(
210
265
  artifacts: ArtifactStore,
211
266
  lineage: WorkflowLineage,
212
- definition: SkillDefinition,
267
+ definition: BlueprintDefinition,
213
268
  input: InstantiateSkillWorkflowInput,
214
269
  history: WorkflowRunHistory | undefined,
215
270
  ancestorSkillIds: ReadonlySet<string>,
@@ -217,7 +272,7 @@ export function materializeWorkflowDefinition(
217
272
  ): WorkflowRunResult {
218
273
  const { ownerId, extraKey, labelPrefix } = lineage;
219
274
  const projectRoot = history?.projectRoot !== undefined ? normalizeProjectRoot(history.projectRoot) : undefined;
220
- const arguments_ = resolveSkillArguments(definition, input.arguments);
275
+ const arguments_ = resolveBlueprintArguments(definition, input.arguments);
221
276
  const rendered = renderDefinition(definition, arguments_);
222
277
  const runId = normalizeRunId(ownerId, input.runId);
223
278
  const refs = [
@@ -231,14 +286,14 @@ export function materializeWorkflowDefinition(
231
286
  // A bound at THIS level's own blueprint size; nested runs are independently bounded the same
232
287
  // way at their own level, and nesting depth is separately capped -- so total blast radius
233
288
  // across a whole pipeline stays bounded on both dimensions even though a step's dependency
234
- // on a skill-call ref can fan out to more edges than this per-level count captures exactly.
289
+ // on a call ref can fan out to more edges than this per-level count captures exactly.
235
290
  const relationshipCount = rendered.links.length
236
291
  + rendered.blueprints.tasks.reduce((count, task) => count + (task.dependsOn?.length ?? 0) + (task.parent ? 2 : 0), 0)
237
292
  + rendered.blueprints.skills.reduce((count, call) => count + (call.dependsOn?.length ?? 0) + (call.parent ? 2 : 0), 0)
238
293
  + rendered.blueprints.tasks.filter((task) => (task.dependsOn?.length ?? 0) === 0).length
239
294
  + rendered.blueprints.skills.filter((call) => (call.dependsOn?.length ?? 0) === 0).length;
240
295
  if (relationshipCount > TASK_EXECUTION_MAX_EDGES) {
241
- throw new Error(`skill workflow run exceeds ${TASK_EXECUTION_MAX_EDGES} relationships`);
296
+ throw new Error(`workflow run exceeds ${TASK_EXECUTION_MAX_EDGES} relationships`);
242
297
  }
243
298
 
244
299
  const docs = rendered.blueprints.docs.map((blueprint) => artifacts.create({
@@ -291,17 +346,21 @@ export function materializeWorkflowDefinition(
291
346
 
292
347
  // Nested pipeline steps run before edge-wiring: dependents need to know what tasks each
293
348
  // nested run actually produced. stepTaskIds/stepRootTaskIds map EVERY step ref (task or
294
- // skill-call) to the task id(s) it resolves to, so dependsOn/parent wiring below treats
295
- // both kinds of step uniformly.
349
+ // call) to the task id(s) it resolves to, so dependsOn/parent wiring below treats both
350
+ // kinds of step uniformly.
296
351
  const nestedRuns: WorkflowRunResult[] = [];
297
352
  const stepTaskIds = new Map<string, string[]>(tasks.map((task, index) => [rendered.blueprints.tasks[index]!.ref, [task.id]]));
298
353
  const stepRootTaskIds = new Map<string, string[]>(
299
354
  tasks.map((task, index) => [rendered.blueprints.tasks[index]!.ref, (rendered.blueprints.tasks[index]!.dependsOn?.length ?? 0) === 0 ? [task.id] : []]),
300
355
  );
301
- for (const call of rendered.blueprints.skills as SkillCallBlueprint[]) {
356
+ // A call ref never gets its own real task -- it resolves to whatever the nested run's entry
357
+ // (or, absent a resolvable one, its first root task) actually is. Lets a focusRef chain
358
+ // straight through an arbitrary number of nested calls down to a real task, recursively.
359
+ const stepEntryTaskIds = new Map<string, string>();
360
+ for (const call of rendered.blueprints.skills as CallBlueprint[]) {
302
361
  const nested = runWorkflowSteps(
303
362
  artifacts,
304
- call.skillId,
363
+ call.targetId,
305
364
  { runId: `${runId}-${call.ref}`, arguments: call.arguments },
306
365
  history,
307
366
  ancestorSkillIds,
@@ -310,6 +369,8 @@ export function materializeWorkflowDefinition(
310
369
  nestedRuns.push(nested);
311
370
  stepTaskIds.set(call.ref, nested.created.tasks);
312
371
  stepRootTaskIds.set(call.ref, nested.rootTaskIds);
372
+ const entry = nested.entryTaskId ?? nested.rootTaskIds[0];
373
+ if (entry !== undefined) stepEntryTaskIds.set(call.ref, entry);
313
374
  }
314
375
 
315
376
  for (const blueprint of rendered.blueprints.tasks) {
@@ -323,7 +384,7 @@ export function materializeWorkflowDefinition(
323
384
  artifacts.link({ from: id, relation: "part_of", to: parentId });
324
385
  }
325
386
  }
326
- for (const call of rendered.blueprints.skills as SkillCallBlueprint[]) {
387
+ for (const call of rendered.blueprints.skills as CallBlueprint[]) {
327
388
  const stepTaskIdsForCall = stepTaskIds.get(call.ref) ?? [];
328
389
  for (const dependency of call.dependsOn ?? []) {
329
390
  for (const dependencyId of stepTaskIds.get(dependency) ?? []) {
@@ -346,17 +407,26 @@ export function materializeWorkflowDefinition(
346
407
 
347
408
  const rootTaskIds = [
348
409
  ...rendered.blueprints.tasks.filter((task) => (task.dependsOn?.length ?? 0) === 0).map((task) => ids.get(task.ref)!),
349
- ...(rendered.blueprints.skills as SkillCallBlueprint[])
410
+ ...(rendered.blueprints.skills as CallBlueprint[])
350
411
  .filter((call) => (call.dependsOn?.length ?? 0) === 0)
351
412
  .flatMap((call) => stepRootTaskIds.get(call.ref) ?? []),
352
413
  ];
353
414
  for (const task of rendered.blueprints.tasks) {
354
415
  if ((task.dependsOn?.length ?? 0) === 0) artifacts.link({ from: ownerId, relation: "triggers", to: ids.get(task.ref)! });
355
416
  }
356
- for (const call of rendered.blueprints.skills as SkillCallBlueprint[]) {
357
- if ((call.dependsOn?.length ?? 0) === 0) artifacts.link({ from: ownerId, relation: "triggers", to: call.skillId });
417
+ for (const call of rendered.blueprints.skills as CallBlueprint[]) {
418
+ if ((call.dependsOn?.length ?? 0) === 0) artifacts.link({ from: ownerId, relation: "triggers", to: call.targetId });
358
419
  }
359
420
 
421
+ // A focusRef naming an ordinary task ref resolves directly through ids; one naming a call
422
+ // ref resolves through the nested run it triggered instead (never through ids, which only
423
+ // maps a call ref to a synthetic placeholder that was never actually created).
424
+ const focusTaskId = input.focusRef === undefined
425
+ ? undefined
426
+ : rendered.blueprints.tasks.some((task) => task.ref === input.focusRef)
427
+ ? ids.get(input.focusRef)
428
+ : stepEntryTaskIds.get(input.focusRef);
429
+
360
430
  return {
361
431
  skillId: ownerId,
362
432
  runId,
@@ -368,7 +438,7 @@ export function materializeWorkflowDefinition(
368
438
  skillRuns: [...nestedRuns.map((run) => run.runId), ...nestedRuns.flatMap((run) => run.created.skillRuns)],
369
439
  },
370
440
  rootTaskIds,
371
- ...(input.focusRef !== undefined && ids.has(input.focusRef) ? { entryTaskId: ids.get(input.focusRef)! } : {}),
441
+ ...(focusTaskId !== undefined ? { entryTaskId: focusTaskId } : {}),
372
442
  execution: projectTaskExecution(executionGraph(tasks, rendered, ids, extraKey)),
373
443
  };
374
444
  }