@oisincoveney/pipeline 2.1.1 → 2.3.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.
Files changed (64) hide show
  1. package/.agents/skills/orchestrate/SKILL.md +80 -0
  2. package/defaults/pipeline.yaml +111 -0
  3. package/defaults/profiles.yaml +215 -0
  4. package/defaults/runners.yaml +24 -0
  5. package/dist/argo-graph.js +59 -27
  6. package/dist/argo-submit.d.ts +0 -1
  7. package/dist/argo-submit.js +11 -6
  8. package/dist/argo-workflow.d.ts +1 -2
  9. package/dist/argo-workflow.js +0 -2
  10. package/dist/claude-settings-config.js +44 -0
  11. package/dist/cli/program.js +12 -5
  12. package/dist/cli/submit-options.js +1 -2
  13. package/dist/cluster-doctor.js +0 -12
  14. package/dist/commands/pipeline-command.js +1 -1
  15. package/dist/config/defaults.js +7 -350
  16. package/dist/config/schemas.d.ts +9 -9
  17. package/dist/install-commands/claude-code.js +160 -0
  18. package/dist/install-commands/opencode.js +56 -39
  19. package/dist/install-commands/shared.js +32 -5
  20. package/dist/install-commands.js +26 -15
  21. package/dist/json-config-merge.js +47 -0
  22. package/dist/mcp/gateway.js +9 -1
  23. package/dist/moka-global-config.d.ts +0 -1
  24. package/dist/moka-global-config.js +0 -1
  25. package/dist/moka-submit.d.ts +8 -11
  26. package/dist/moka-submit.js +1 -4
  27. package/dist/opencode-project-config.js +2 -45
  28. package/dist/pipeline-runtime.d.ts +9 -0
  29. package/dist/pipeline-runtime.js +50 -8
  30. package/dist/planned-node.js +2 -5
  31. package/dist/{workflow-planner.d.ts → planning/compile.d.ts} +2 -2
  32. package/dist/{workflow-planner.js → planning/compile.js} +6 -83
  33. package/dist/{schedule/planner.d.ts → planning/generate.d.ts} +17 -3
  34. package/dist/{schedule/planner.js → planning/generate.js} +24 -56
  35. package/dist/planning/graph.js +138 -0
  36. package/dist/runner-command/lifecycle-context.js +2 -3
  37. package/dist/runner-command/run.js +2 -3
  38. package/dist/runner-command-contract.d.ts +2 -2
  39. package/dist/runner-event-schema.d.ts +349 -0
  40. package/dist/runner-event-schema.js +185 -0
  41. package/dist/runner-output.js +2 -2
  42. package/dist/runner.d.ts +29 -0
  43. package/dist/runtime/agent-node/agent-node.js +1 -0
  44. package/dist/runtime/context/context.js +1 -1
  45. package/dist/runtime/contracts/contracts.d.ts +18 -1
  46. package/dist/runtime/node-state-store.js +7 -0
  47. package/dist/runtime/opencode-adapter.js +28 -8
  48. package/dist/runtime/opencode-agent-name.js +18 -0
  49. package/dist/runtime/opencode-runtime.js +62 -0
  50. package/dist/runtime/opencode-server.js +67 -0
  51. package/dist/runtime/opencode-session-executor.js +206 -0
  52. package/dist/schedule/passes/coverage.js +7 -51
  53. package/dist/schedule/passes/ids.js +3 -23
  54. package/dist/schedule/scheduling-roles.js +19 -0
  55. package/dist/strings.js +30 -1
  56. package/docs/adr-opencode-first-goal-loop-runtime.md +1 -1
  57. package/docs/config-architecture.md +43 -6
  58. package/docs/mcp-gateway.md +4 -4
  59. package/docs/operator-guide.md +9 -8
  60. package/docs/pipeline-console-runner-contract.md +3 -4
  61. package/docs/slash-command-adapter-contract.md +1 -0
  62. package/package.json +10 -5
  63. package/dist/schedule-planner.d.ts +0 -2
  64. package/dist/schedule-planner.js +0 -2
@@ -1,36 +1,17 @@
1
- import { applyEdits, modify, parse } from "jsonc-parser";
1
+ import { applyJsonEdit, ensureTrailingNewline, formatJson, parseJsonRecord, setIfMissing } from "./json-config-merge.js";
2
2
  //#region src/opencode-project-config.ts
3
- const JSON_FORMAT_OPTIONS = {
4
- insertSpaces: true,
5
- tabSize: 2
6
- };
7
3
  function mergeOpenCodeProjectConfig(currentText, projection) {
8
4
  if (currentText === void 0) return {
9
5
  content: formatJson(projection),
10
6
  ok: true
11
7
  };
12
- const parsed = parseOpenCodeProjectConfig(currentText);
8
+ const parsed = parseJsonRecord(currentText);
13
9
  if (!parsed.ok) return parsed;
14
10
  return {
15
11
  content: ensureTrailingNewline(applyOpenCodeProjection(currentText, parsed.value, projection)),
16
12
  ok: true
17
13
  };
18
14
  }
19
- function parseOpenCodeProjectConfig(currentText) {
20
- const errors = [];
21
- const value = parse(currentText, errors, {
22
- allowTrailingComma: true,
23
- disallowComments: false
24
- });
25
- if (errors.length > 0 || !isRecord(value)) return {
26
- errors,
27
- ok: false
28
- };
29
- return {
30
- ok: true,
31
- value
32
- };
33
- }
34
15
  function applyOpenCodeProjection(currentText, parsed, projection) {
35
16
  return applyProviderProjection(applyPluginProjection(applyMcpProjection(setIfMissing(setIfMissing(currentText, parsed, ["$schema"], projection.$schema), parsed, ["lsp"], projection.lsp), parsed, projection), parsed, projection), parsed, projection);
36
17
  }
@@ -49,18 +30,6 @@ function applyPluginProjection(content, parsed, projection) {
49
30
  const plugins = mergePluginEntries(parsed.plugin, projection.plugin ?? []);
50
31
  return plugins.length > 0 ? applyJsonEdit(content, ["plugin"], plugins) : content;
51
32
  }
52
- function setIfMissing(content, parsed, path, value) {
53
- if (value === void 0 || hasPath(parsed, path)) return content;
54
- return applyJsonEdit(content, path, value);
55
- }
56
- function hasPath(value, path) {
57
- let cursor = value;
58
- for (const segment of path) {
59
- if (!(isRecord(cursor) && segment in cursor)) return false;
60
- cursor = cursor[segment];
61
- }
62
- return true;
63
- }
64
33
  function mergePluginEntries(existing, projected) {
65
34
  const projectedByKey = new Map(projected.map((plugin) => [pluginKey(plugin), plugin]));
66
35
  const merged = [];
@@ -86,17 +55,5 @@ function pluginName(specifier) {
86
55
  const versionSeparator = specifier.indexOf("@", 1);
87
56
  return versionSeparator === -1 ? specifier : specifier.slice(0, versionSeparator);
88
57
  }
89
- function applyJsonEdit(content, path, value) {
90
- return applyEdits(content, modify(content, path, value, { formattingOptions: JSON_FORMAT_OPTIONS }));
91
- }
92
- function formatJson(value) {
93
- return `${JSON.stringify(value, null, 2)}\n`;
94
- }
95
- function ensureTrailingNewline(value) {
96
- return value.endsWith("\n") ? value : `${value}\n`;
97
- }
98
- function isRecord(value) {
99
- return typeof value === "object" && value !== null && !Array.isArray(value);
100
- }
101
58
  //#endregion
102
59
  export { mergeOpenCodeProjectConfig };
@@ -1,6 +1,15 @@
1
1
  import { PipelineConfigError } from "./config/schemas.js";
2
2
  import { AcceptanceCriterion, HookRuntimePolicy, NodeExecutionState, NodeStatus, PipelineRuntimeEvent, PipelineRuntimeObservabilityLevel, PipelineRuntimeOptions, PipelineRuntimeResult, PipelineTaskContext, RuntimeFailure, RuntimeGateResult, RuntimeNodeResult, RuntimeStructuredOutput } from "./runtime/contracts/contracts.js";
3
3
  //#region src/pipeline-runtime.d.ts
4
+ /**
5
+ * Top layer of the runtime-options stack (PIPE-74 B3). Extends
6
+ * {@link PipelineRuntimeOptions} for the schedule-driven path that runs a
7
+ * SINGLE workflow node (`nodeId`) in isolation, supplying that node's upstream
8
+ * `dependencyOutputs`. Full stack:
9
+ * RunnerExecutionOptions (src/runner.ts)
10
+ * < PipelineRuntimeOptions (src/runtime/contracts/contracts.ts)
11
+ * < ScheduledWorkflowTaskRuntimeOptions (this type)
12
+ */
4
13
  interface ScheduledWorkflowTaskRuntimeOptions extends PipelineRuntimeOptions {
5
14
  dependencyOutputs?: Map<string, string> | Record<string, string>;
6
15
  nodeId: string;
@@ -1,3 +1,5 @@
1
+ import { loadPipelineConfig } from "./config/load.js";
2
+ import "./config.js";
1
3
  import { executeCommand } from "./runtime/command-executor/command-executor.js";
2
4
  import "./runtime/command-executor/index.js";
3
5
  import { parseJsonObject } from "./runtime/json-validation/json-validation.js";
@@ -17,24 +19,64 @@ import { diffChangedFiles, snapshotChangedFiles } from "./runtime/changed-files/
17
19
  import { evaluateNodeGates } from "./runtime/gates/gates.js";
18
20
  import "./runtime/gates/index.js";
19
21
  import { NodeStateTracker } from "./runtime/node-state-tracker.js";
22
+ import { configUsesOpencode, leaseOpencodeRuntime } from "./runtime/opencode-runtime.js";
20
23
  import { executeParallelNode } from "./runtime/parallel-node/parallel-node.js";
21
24
  import "./runtime/parallel-node/index.js";
22
25
  import { decideNodeRetry, nodeRetryPolicy } from "./runtime/retry.js";
23
26
  import { LocalScheduler } from "./runtime/scheduler.js";
24
27
  //#region src/pipeline-runtime.ts
25
28
  function runPipelineFromConfig(options) {
26
- return runPipelineWithContext(createRuntimeContext(options));
29
+ return withOpencodeRuntime(options, (resolved) => runPipelineWithContext(createRuntimeContext(resolved)));
27
30
  }
28
31
  function runScheduledWorkflowTask(options) {
29
32
  const { dependencyOutputs, nodeId, ...runtimeOptions } = options;
30
- const context = createRuntimeContext(runtimeOptions);
31
- hydrateScheduledDependencyStates(context, nodeId);
32
- hydrateDependencyOutputs(context, dependencyOutputs);
33
- recordNodeEvent(context, nodeId, {
34
- at: now(),
35
- type: "READY"
33
+ return withOpencodeRuntime(runtimeOptions, (resolved) => {
34
+ const context = createRuntimeContext(resolved);
35
+ hydrateScheduledDependencyStates(context, nodeId);
36
+ hydrateDependencyOutputs(context, dependencyOutputs);
37
+ recordNodeEvent(context, nodeId, {
38
+ at: now(),
39
+ type: "READY"
40
+ });
41
+ return executePlannedNode(nodeId, context);
42
+ });
43
+ }
44
+ /**
45
+ * When the config uses opencode and the caller did not inject an executor,
46
+ * open one opencode server for the run, drive nodes through the SDK executor,
47
+ * and tear the server down afterward. Command-only configs and callers that
48
+ * supply their own executor (tests, embedders) are passed through untouched.
49
+ */
50
+ async function withOpencodeRuntime(options, run) {
51
+ if (options.executor) return await run(options);
52
+ const { config, worktreePath } = resolveConfigForRun(options);
53
+ return configUsesOpencode(config) ? await runWithLeasedOpencode(options, config, worktreePath, run) : await run({
54
+ ...options,
55
+ config
56
+ });
57
+ }
58
+ function resolveConfigForRun(options) {
59
+ const worktreePath = options.worktreePath ?? process.cwd();
60
+ return {
61
+ config: options.config ?? loadPipelineConfig(worktreePath),
62
+ worktreePath
63
+ };
64
+ }
65
+ async function runWithLeasedOpencode(options, config, worktreePath, run) {
66
+ const lease = await leaseOpencodeRuntime({
67
+ config,
68
+ ...options.signal ? { signal: options.signal } : {},
69
+ worktreePath
36
70
  });
37
- return executePlannedNode(nodeId, context);
71
+ try {
72
+ return await run({
73
+ ...options,
74
+ config,
75
+ executor: lease.executor
76
+ });
77
+ } finally {
78
+ await lease.release();
79
+ }
38
80
  }
39
81
  async function runPipelineWithContext(context) {
40
82
  return finishRuntime(context, await new LocalScheduler({
@@ -1,10 +1,7 @@
1
+ import { findNode } from "./planning/graph.js";
1
2
  //#region src/planned-node.ts
2
3
  function findPlannedNode(nodes, nodeId) {
3
- for (const node of nodes) {
4
- if (node.id === nodeId) return node;
5
- const child = findPlannedNode(node.children ?? [], nodeId);
6
- if (child) return child;
7
- }
4
+ return findNode(nodes, nodeId, (node) => node.children);
8
5
  }
9
6
  //#endregion
10
7
  export { findPlannedNode };
@@ -1,7 +1,7 @@
1
- import { PipelineConfig, WorkflowNodeKind } from "./config/schemas.js";
1
+ import { PipelineConfig, WorkflowNodeKind } from "../config/schemas.js";
2
2
  import { Graph } from "@dagrejs/graphlib";
3
3
 
4
- //#region src/workflow-planner.d.ts
4
+ //#region src/planning/compile.d.ts
5
5
  type WorkflowPlannerErrorCode = "WORKFLOW_CYCLE" | "WORKFLOW_DUPLICATE_NODE" | "WORKFLOW_GROUP_REFERENCE" | "WORKFLOW_MISSING_DEPENDENCY" | "WORKFLOW_MISSING_WORKFLOW";
6
6
  interface WorkflowPlannerIssue {
7
7
  message: string;
@@ -1,6 +1,7 @@
1
- import { uniqueStrings } from "./strings.js";
1
+ import { uniqueStrings } from "../strings.js";
2
+ import { findDependencyCycles } from "./graph.js";
2
3
  import { Graph } from "@dagrejs/graphlib";
3
- //#region src/workflow-planner.ts
4
+ //#region src/planning/compile.ts
4
5
  var WorkflowPlannerError = class extends Error {
5
6
  code;
6
7
  issues;
@@ -54,7 +55,7 @@ function validateNodeGraph(workflowId, nodes) {
54
55
  ...groupIssues(workflowId, nodes, nodeIds),
55
56
  ...dependencyIssues(workflowId, nodes, nodeIds)
56
57
  ];
57
- if (duplicateIssues.length === 0) return [...issues, ...cycleIssues(workflowId, nodes, nodeIds)];
58
+ if (duplicateIssues.length === 0) return [...issues, ...cycleIssues(workflowId, nodes)];
58
59
  return issues;
59
60
  }
60
61
  function duplicateNodeIssues(workflowId, nodes) {
@@ -100,92 +101,14 @@ function groupChildIssues(workflowId, node, nodeIds) {
100
101
  function isGroupNode(node) {
101
102
  return node.kind === "group";
102
103
  }
103
- function cycleIssues(workflowId, nodes, nodeIds) {
104
- return dependencyCycles(nodes, nodeIds).map((cycle) => {
104
+ function cycleIssues(workflowId, nodes) {
105
+ return findDependencyCycles(nodes).map((cycle) => {
105
106
  return {
106
107
  path: `workflows.${workflowId}.nodes.${cycle[0] ?? "nodes"}.needs`,
107
108
  message: `workflow '${workflowId}' contains dependency cycle: ${cycle.join(" -> ")}`
108
109
  };
109
110
  });
110
111
  }
111
- function dependencyCycles(nodes, nodeIds) {
112
- const dependentsByNeed = dependentsByNeedMap(nodes, nodeIds);
113
- const state = /* @__PURE__ */ new Map();
114
- const path = [];
115
- const pathIndex = /* @__PURE__ */ new Map();
116
- const cycles = [];
117
- const cycleKeys = /* @__PURE__ */ new Set();
118
- for (const node of nodes) {
119
- if (state.has(node.id)) continue;
120
- visitForCycles(node.id, {
121
- cycleKeys,
122
- cycles,
123
- dependentsByNeed,
124
- path,
125
- pathIndex,
126
- state
127
- });
128
- }
129
- return cycles;
130
- }
131
- function visitForCycles(startId, visitState) {
132
- const frames = [{
133
- index: 0,
134
- nodeId: startId
135
- }];
136
- markVisiting(startId, visitState);
137
- while (frames.length > 0) {
138
- const frame = frames.at(-1);
139
- if (!frame) return;
140
- const dependentId = (visitState.dependentsByNeed.get(frame.nodeId) ?? [])[frame.index];
141
- if (!dependentId) {
142
- markDone(frame.nodeId, visitState);
143
- frames.pop();
144
- continue;
145
- }
146
- frame.index += 1;
147
- const dependentState = visitState.state.get(dependentId);
148
- if (dependentState === "visiting") {
149
- recordCycle(dependentId, visitState);
150
- continue;
151
- }
152
- if (dependentState === "done") continue;
153
- markVisiting(dependentId, visitState);
154
- frames.push({
155
- index: 0,
156
- nodeId: dependentId
157
- });
158
- }
159
- }
160
- function markVisiting(nodeId, visitState) {
161
- visitState.state.set(nodeId, "visiting");
162
- visitState.pathIndex.set(nodeId, visitState.path.length);
163
- visitState.path.push(nodeId);
164
- }
165
- function markDone(nodeId, visitState) {
166
- visitState.state.set(nodeId, "done");
167
- visitState.pathIndex.delete(nodeId);
168
- visitState.path.pop();
169
- }
170
- function recordCycle(nodeId, visitState) {
171
- const startIndex = visitState.pathIndex.get(nodeId);
172
- if (startIndex === void 0) return;
173
- const cycle = visitState.path.slice(startIndex);
174
- const key = [...cycle].sort().join("\0");
175
- if (visitState.cycleKeys.has(key)) return;
176
- visitState.cycleKeys.add(key);
177
- visitState.cycles.push(cycle);
178
- }
179
- function dependentsByNeedMap(nodes, nodeIds) {
180
- const dependentsByNeed = /* @__PURE__ */ new Map();
181
- for (const node of nodes) for (const need of uniqueStrings(node.needs ?? [])) {
182
- if (!nodeIds.has(need)) continue;
183
- const dependents = dependentsByNeed.get(need) ?? [];
184
- dependents.push(node.id);
185
- dependentsByNeed.set(need, dependents);
186
- }
187
- return dependentsByNeed;
188
- }
189
112
  function topologicalOrderForPlan(graph) {
190
113
  const visited = /* @__PURE__ */ new Set();
191
114
  const inStack = /* @__PURE__ */ new Set();
@@ -1,9 +1,9 @@
1
1
  import { PipelineConfig } from "../config/schemas.js";
2
- import { WorkflowExecutionPlan } from "../workflow-planner.js";
2
+ import { WorkflowExecutionPlan } from "./compile.js";
3
3
  import { AgentResult, RunnerExecutionOptions, RunnerLaunchPlan } from "../runner.js";
4
4
  import { z } from "zod";
5
5
 
6
- //#region src/schedule/planner.d.ts
6
+ //#region src/planning/generate.d.ts
7
7
  declare const scheduleArtifactSchema: z.ZodObject<{
8
8
  generated_at: z.ZodString;
9
9
  kind: z.ZodLiteral<"pipeline-schedule">;
@@ -1575,6 +1575,20 @@ interface GenerateScheduleResult {
1575
1575
  artifact: ScheduleArtifact;
1576
1576
  path: string;
1577
1577
  }
1578
+ interface BacklogWorkUnit {
1579
+ acceptance_criteria: Array<{
1580
+ id: string;
1581
+ text: string;
1582
+ }>;
1583
+ dependencies?: string[];
1584
+ description?: string;
1585
+ id: string;
1586
+ title?: string;
1587
+ }
1588
+ interface SchedulePlanningContext {
1589
+ parentWorkUnits: BacklogWorkUnit[];
1590
+ workUnits: BacklogWorkUnit[];
1591
+ }
1578
1592
  declare function parseScheduleArtifact(source: string, sourcePath?: string): ScheduleArtifact;
1579
1593
  declare class ScheduleArtifactError extends Error {
1580
1594
  constructor(message: string);
@@ -1583,4 +1597,4 @@ declare function compileScheduleArtifact(config: PipelineConfig, artifact: Sched
1583
1597
  declare function generateScheduleArtifact(options: GenerateScheduleOptions): Promise<GenerateScheduleResult>;
1584
1598
  declare function scheduleArtifactPath(worktreePath: string, scheduleId: string): string;
1585
1599
  //#endregion
1586
- export { CompiledScheduleArtifact, GenerateScheduleOptions, GenerateScheduleResult, ScheduleArtifact, ScheduleArtifactError, compileScheduleArtifact, generateScheduleArtifact, parseScheduleArtifact, scheduleArtifactPath };
1600
+ export { BacklogWorkUnit, CompiledScheduleArtifact, GenerateScheduleOptions, GenerateScheduleResult, ScheduleArtifact, ScheduleArtifactError, SchedulePlanningContext, compileScheduleArtifact, generateScheduleArtifact, parseScheduleArtifact, scheduleArtifactPath };
@@ -3,20 +3,22 @@ import { validatePipelineConfig } from "../config/validate.js";
3
3
  import "../config.js";
4
4
  import { createRunnerLaunchPlan, runLaunchPlan } from "../runner.js";
5
5
  import { normalizeRunnerOutput } from "../runner-output.js";
6
- import { compileWorkflowPlan } from "../workflow-planner.js";
7
- import { loadBacklogPlanningContext } from "./backlog-context.js";
8
- import { baselineScheduleArtifact } from "./baseline.js";
9
- import { addGeneratedImplementationCoverage } from "./passes/coverage.js";
10
- import { canonicalizeGeneratedScheduleIds } from "./passes/ids.js";
11
- import { SCHEDULE_PASS_ORDER } from "./passes/index.js";
12
- import { applyNodeCatalogModelFallbacks } from "./passes/models.js";
13
- import { namespaceScheduleWorkflows } from "./passes/references.js";
14
- import { plannerPrompt, plannerRepairPrompt } from "./prompts.js";
6
+ import { loadBacklogPlanningContext } from "../schedule/backlog-context.js";
7
+ import { baselineScheduleArtifact } from "../schedule/baseline.js";
8
+ import { dependentsByNeed, flattenNodes, hasReachableDependent } from "./graph.js";
9
+ import { isCoverageNode, isImplementationNode } from "../schedule/scheduling-roles.js";
10
+ import { addGeneratedImplementationCoverage } from "../schedule/passes/coverage.js";
11
+ import { canonicalizeGeneratedScheduleIds } from "../schedule/passes/ids.js";
12
+ import { SCHEDULE_PASS_ORDER } from "../schedule/passes/index.js";
13
+ import { applyNodeCatalogModelFallbacks } from "../schedule/passes/models.js";
14
+ import { namespaceScheduleWorkflows } from "../schedule/passes/references.js";
15
+ import { plannerPrompt, plannerRepairPrompt } from "../schedule/prompts.js";
16
+ import { compileWorkflowPlan } from "./compile.js";
15
17
  import { parseDocument, stringify } from "yaml";
16
18
  import { z } from "zod";
17
19
  import { mkdirSync, writeFileSync } from "node:fs";
18
20
  import { join } from "node:path";
19
- //#region src/schedule/planner.ts
21
+ //#region src/planning/generate.ts
20
22
  const SCHEDULE_KIND = "pipeline-schedule";
21
23
  const ID_RE = /^[a-z][a-z0-9-]*$/;
22
24
  const SCHEDULE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
@@ -253,9 +255,9 @@ function unsafeParallelWorktreeIssues(config, artifact) {
253
255
  }
254
256
  function workflowNodeIssues(artifact, collectIssues) {
255
257
  return Object.entries(artifact.workflows).flatMap(([workflowId, workflow]) => {
256
- const nodes = workflow.nodes.flatMap(flattenWorkflowNode);
258
+ const nodes = flattenWorkflowNodes(workflow.nodes);
257
259
  return collectIssues({
258
- dependentsByNeed: workflowDependentsByNeed(nodes),
260
+ dependentsByNeed: dependentsByNeed(nodes),
259
261
  nodes,
260
262
  workflowId
261
263
  });
@@ -267,20 +269,8 @@ function isWriteCapableParallelChild(config, node) {
267
269
  if (node.kind === "parallel") return node.nodes.some((child) => isWriteCapableParallelChild(config, child));
268
270
  return false;
269
271
  }
270
- function hasDownstreamDrainMerge(nodeId, dependentsByNeed) {
271
- return hasReachableDependent(nodeId, dependentsByNeed, (node) => node.kind === "builtin" && node.builtin === "drain-merge");
272
- }
273
- function hasReachableDependent(nodeId, dependentsByNeed, matches) {
274
- const queue = [...dependentsByNeed.get(nodeId) ?? []];
275
- const seen = /* @__PURE__ */ new Set();
276
- while (queue.length > 0) {
277
- const node = queue.shift();
278
- if (!node || seen.has(node.id)) continue;
279
- seen.add(node.id);
280
- if (matches(node)) return true;
281
- queue.push(...dependentsByNeed.get(node.id) ?? []);
282
- }
283
- return false;
272
+ function hasDownstreamDrainMerge(nodeId, index) {
273
+ return hasReachableDependent(nodeId, index, (node) => node.kind === "builtin" && node.builtin === "drain-merge");
284
274
  }
285
275
  function generatedRootWorkflowIssues(artifact) {
286
276
  const workflowIds = Object.keys(artifact.workflows);
@@ -331,15 +321,15 @@ function workUnitDependencyIssues(config, artifact, workUnits) {
331
321
  const workUnitIds = new Set(workUnits.map((unit) => unit.id));
332
322
  const dependenciesByUnit = new Map(workUnits.map((unit) => [unit.id, (unit.dependencies ?? []).filter((id) => workUnitIds.has(id))]));
333
323
  return Object.entries(artifact.workflows).flatMap(([workflowId, workflow]) => {
334
- const nodes = workflow.nodes.flatMap(flattenWorkflowNode);
335
- const dependentsByNeed = workflowDependentsByNeed(nodes);
324
+ const nodes = flattenWorkflowNodes(workflow.nodes);
325
+ const index = dependentsByNeed(nodes);
336
326
  const nodesByWorkUnit = nodesByAssignedWorkUnit(nodes);
337
327
  return nodes.filter((node) => isImplementationNode(config, node)).flatMap((node) => {
338
328
  const dependentId = node.task_context?.id;
339
329
  if (!dependentId) return [];
340
330
  return (dependenciesByUnit.get(dependentId) ?? []).flatMap((prerequisiteId) => {
341
331
  const prerequisiteNodes = nodesByWorkUnit.get(prerequisiteId) ?? [];
342
- return prerequisiteNodes.some((source) => hasPathToNode(source.id, node.id, dependentsByNeed)) ? [] : [`work unit dependency edge missing in '${workflowId}': '${dependentId}' node '${node.id}' must depend on prerequisite '${prerequisiteId}' nodes ${prerequisiteNodes.map((prerequisite) => `'${prerequisite.id}'`).join(", ")}`];
332
+ return prerequisiteNodes.some((source) => hasReachableDependent(source.id, index, (candidate) => candidate.id === node.id)) ? [] : [`work unit dependency edge missing in '${workflowId}': '${dependentId}' node '${node.id}' must depend on prerequisite '${prerequisiteId}' nodes ${prerequisiteNodes.map((prerequisite) => `'${prerequisite.id}'`).join(", ")}`];
343
333
  });
344
334
  });
345
335
  });
@@ -355,9 +345,6 @@ function nodesByAssignedWorkUnit(nodes) {
355
345
  }
356
346
  return grouped;
357
347
  }
358
- function hasPathToNode(sourceId, targetId, dependentsByNeed) {
359
- return hasReachableDependent(sourceId, dependentsByNeed, (node) => node.id === targetId);
360
- }
361
348
  function unsupportedGeneratedBuiltinIssues(artifact) {
362
349
  const allowed = new Set(SCHEDULE_BUILTINS);
363
350
  return allWorkflowNodes(artifact.workflows).flatMap((node) => {
@@ -369,33 +356,14 @@ function unsupportedGeneratedBuiltinIssues(artifact) {
369
356
  function implementationCoverageIssues(config, artifact) {
370
357
  return workflowNodeIssues(artifact, ({ dependentsByNeed, nodes, workflowId }) => nodes.filter((node) => isImplementationNode(config, node)).filter((node) => !hasDownstreamCoverage(config, node.id, dependentsByNeed)).map((node) => `implementation node '${workflowId}.${node.id}' is without downstream verification or review`));
371
358
  }
372
- function isImplementationNode(config, node) {
373
- return hasSchedulingRole(config, node, "implementation");
374
- }
375
- function workflowDependentsByNeed(nodes) {
376
- const dependentsByNeed = /* @__PURE__ */ new Map();
377
- for (const node of nodes) for (const need of node.needs ?? []) {
378
- const dependents = dependentsByNeed.get(need) ?? [];
379
- dependents.push(node);
380
- dependentsByNeed.set(need, dependents);
381
- }
382
- return dependentsByNeed;
383
- }
384
- function hasDownstreamCoverage(config, nodeId, dependentsByNeed) {
385
- return hasReachableDependent(nodeId, dependentsByNeed, (node) => isCoverageNode(config, node));
386
- }
387
- function isCoverageNode(config, node) {
388
- return hasSchedulingRole(config, node, "coverage");
389
- }
390
- function hasSchedulingRole(config, node, role) {
391
- if (node.kind !== "agent") return false;
392
- return config.profiles[node.profile]?.scheduling_roles?.includes(role) ?? false;
359
+ function hasDownstreamCoverage(config, nodeId, index) {
360
+ return hasReachableDependent(nodeId, index, (node) => isCoverageNode(config, node));
393
361
  }
394
362
  function allWorkflowNodes(workflows) {
395
- return Object.values(workflows).flatMap((workflow) => workflow.nodes.flatMap(flattenWorkflowNode));
363
+ return Object.values(workflows).flatMap((workflow) => flattenWorkflowNodes(workflow.nodes));
396
364
  }
397
- function flattenWorkflowNode(node) {
398
- return node.kind === "parallel" ? [node, ...node.nodes.flatMap(flattenWorkflowNode)] : [node];
365
+ function flattenWorkflowNodes(nodes) {
366
+ return flattenNodes(nodes, (node) => node.kind === "parallel" ? node.nodes : void 0);
399
367
  }
400
368
  //#endregion
401
369
  export { ScheduleArtifactError, compileScheduleArtifact, generateScheduleArtifact, parseScheduleArtifact, scheduleArtifactPath };
@@ -0,0 +1,138 @@
1
+ //#region src/planning/graph.ts
2
+ /**
3
+ * Depth-first flatten of a node tree into a flat list (parents before
4
+ * children), using the caller-supplied accessor to descend into nested
5
+ * containers (e.g. parallel children). Nodes without children are returned
6
+ * as-is.
7
+ */
8
+ function flattenNodes(nodes, childrenOf) {
9
+ return nodes.flatMap((node) => {
10
+ const children = childrenOf(node);
11
+ return children && children.length > 0 ? [node, ...flattenNodes(children, childrenOf)] : [node];
12
+ });
13
+ }
14
+ /**
15
+ * Build an index mapping each `need` id to the (flat) nodes that declare it as a
16
+ * dependency. The input is expected to already be flattened by the caller so
17
+ * that the resulting index spans the whole graph.
18
+ */
19
+ function dependentsByNeed(nodes) {
20
+ const index = /* @__PURE__ */ new Map();
21
+ for (const node of nodes) for (const need of node.needs ?? []) {
22
+ const dependents = index.get(need) ?? [];
23
+ dependents.push(node);
24
+ index.set(need, dependents);
25
+ }
26
+ return index;
27
+ }
28
+ /**
29
+ * Breadth-first search over the dependents index to determine whether any node
30
+ * transitively reachable downstream of `nodeId` satisfies `matches`. Cycle-safe
31
+ * via a visited set.
32
+ */
33
+ function hasReachableDependent(nodeId, index, matches) {
34
+ const visited = /* @__PURE__ */ new Set();
35
+ const queue = [...index.get(nodeId) ?? []];
36
+ while (queue.length > 0) {
37
+ const node = queue.shift();
38
+ if (!node || visited.has(node.id)) continue;
39
+ visited.add(node.id);
40
+ if (matches(node)) return true;
41
+ queue.push(...index.get(node.id) ?? []);
42
+ }
43
+ return false;
44
+ }
45
+ /**
46
+ * Recursively find a node by id within a node tree, descending into children
47
+ * via the caller-supplied accessor. Returns `undefined` when absent.
48
+ */
49
+ function findNode(nodes, nodeId, childrenOf) {
50
+ for (const node of nodes) {
51
+ if (node.id === nodeId) return node;
52
+ const child = findNode(childrenOf(node) ?? [], nodeId, childrenOf);
53
+ if (child) return child;
54
+ }
55
+ }
56
+ /**
57
+ * Find all dependency cycles in a flat node list using an iterative DFS over the
58
+ * dependents-by-need graph. Each cycle is returned once (canonicalized by its
59
+ * member set) as the ordered list of node ids forming the loop. Only `needs`
60
+ * that reference declared nodes participate, so callers can pass the full flat
61
+ * node set without pre-filtering missing dependencies.
62
+ *
63
+ * The traversal is iterative (an explicit frame stack) rather than recursive so
64
+ * that deep generated workflow chains cannot overflow the call stack.
65
+ */
66
+ function findDependencyCycles(nodes) {
67
+ const nodeIds = new Set(nodes.map((node) => node.id));
68
+ const index = dependentsByNeed(nodes);
69
+ const adjacency = /* @__PURE__ */ new Map();
70
+ for (const node of nodes) adjacency.set(node.id, (index.get(node.id) ?? []).map((dependent) => dependent.id).filter((id) => nodeIds.has(id)));
71
+ const state = /* @__PURE__ */ new Map();
72
+ const path = [];
73
+ const pathIndex = /* @__PURE__ */ new Map();
74
+ const cycles = [];
75
+ const cycleKeys = /* @__PURE__ */ new Set();
76
+ for (const node of nodes) {
77
+ if (state.has(node.id)) continue;
78
+ visitForCycles(node.id, {
79
+ adjacency,
80
+ cycleKeys,
81
+ cycles,
82
+ path,
83
+ pathIndex,
84
+ state
85
+ });
86
+ }
87
+ return cycles;
88
+ }
89
+ function visitForCycles(startId, visitState) {
90
+ const frames = [{
91
+ index: 0,
92
+ nodeId: startId
93
+ }];
94
+ markVisiting(startId, visitState);
95
+ while (frames.length > 0) {
96
+ const frame = frames.at(-1);
97
+ if (!frame) return;
98
+ const dependentId = (visitState.adjacency.get(frame.nodeId) ?? [])[frame.index];
99
+ if (!dependentId) {
100
+ markDone(frame.nodeId, visitState);
101
+ frames.pop();
102
+ continue;
103
+ }
104
+ frame.index += 1;
105
+ const dependentState = visitState.state.get(dependentId);
106
+ if (dependentState === "visiting") {
107
+ recordCycle(dependentId, visitState);
108
+ continue;
109
+ }
110
+ if (dependentState === "done") continue;
111
+ markVisiting(dependentId, visitState);
112
+ frames.push({
113
+ index: 0,
114
+ nodeId: dependentId
115
+ });
116
+ }
117
+ }
118
+ function markVisiting(nodeId, visitState) {
119
+ visitState.state.set(nodeId, "visiting");
120
+ visitState.pathIndex.set(nodeId, visitState.path.length);
121
+ visitState.path.push(nodeId);
122
+ }
123
+ function markDone(nodeId, visitState) {
124
+ visitState.state.set(nodeId, "done");
125
+ visitState.pathIndex.delete(nodeId);
126
+ visitState.path.pop();
127
+ }
128
+ function recordCycle(nodeId, visitState) {
129
+ const startIndex = visitState.pathIndex.get(nodeId);
130
+ if (startIndex === void 0) return;
131
+ const cycle = visitState.path.slice(startIndex);
132
+ const key = [...cycle].sort().join("\0");
133
+ if (visitState.cycleKeys.has(key)) return;
134
+ visitState.cycleKeys.add(key);
135
+ visitState.cycles.push(cycle);
136
+ }
137
+ //#endregion
138
+ export { dependentsByNeed, findDependencyCycles, findNode, flattenNodes, hasReachableDependent };
@@ -1,9 +1,8 @@
1
- import { parseRunnerCommandPayload, resolveRunnerEventSinkAuthToken } from "../runner-command-contract.js";
2
1
  import { PIPELINE_CONFIG_PATH, PROFILES_CONFIG_PATH, RUNNERS_CONFIG_PATH } from "../config/defaults.js";
3
2
  import { loadPipelineConfig, parsePipelineConfigParts } from "../config/load.js";
4
3
  import "../config.js";
5
- import { compileScheduleArtifact, parseScheduleArtifact } from "../schedule/planner.js";
6
- import "../schedule-planner.js";
4
+ import { compileScheduleArtifact, parseScheduleArtifact } from "../planning/generate.js";
5
+ import { parseRunnerCommandPayload, resolveRunnerEventSinkAuthToken } from "../runner-command-contract.js";
7
6
  import { prepareRunnerGitWorkspace } from "../run-state/git-refs.js";
8
7
  import { createRunnerEventSink } from "../runner-event-sink.js";
9
8
  import { initialNodeStateStore } from "../runtime/node-state-store.js";
@@ -1,9 +1,8 @@
1
1
  import { readRunnerTaskDescriptor } from "./task-descriptor.js";
2
- import { RunnerCommandPayloadValidationError, parseRunnerCommandPayload, resolveRunnerEventSinkAuthToken } from "../runner-command-contract.js";
3
2
  import { loadPipelineConfig } from "../config/load.js";
4
3
  import "../config.js";
5
- import { compileScheduleArtifact, parseScheduleArtifact } from "../schedule/planner.js";
6
- import "../schedule-planner.js";
4
+ import { compileScheduleArtifact, parseScheduleArtifact } from "../planning/generate.js";
5
+ import { RunnerCommandPayloadValidationError, parseRunnerCommandPayload, resolveRunnerEventSinkAuthToken } from "../runner-command-contract.js";
7
6
  import { commitAndPushNodeRef, mergeDependencyRefs, prepareRunnerGitWorkspace } from "../run-state/git-refs.js";
8
7
  import { createRunnerEventSink } from "../runner-event-sink.js";
9
8
  import { findPlannedNode } from "../planned-node.js";
@@ -43,8 +43,8 @@ declare const runnerDeliverySchema: z.ZodObject<{
43
43
  declare const mokaSubmissionSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
44
44
  kind: z.ZodLiteral<"graph">;
45
45
  mode: z.ZodEnum<{
46
- quick: "quick";
47
46
  full: "full";
47
+ quick: "quick";
48
48
  }>;
49
49
  }, z.core.$strict>, z.ZodObject<{
50
50
  argv: z.ZodArray<z.ZodString>;
@@ -104,8 +104,8 @@ declare const runnerCommandPayloadSchema: z.ZodObject<{
104
104
  submission: z.ZodDefault<z.ZodDiscriminatedUnion<[z.ZodObject<{
105
105
  kind: z.ZodLiteral<"graph">;
106
106
  mode: z.ZodEnum<{
107
- quick: "quick";
108
107
  full: "full";
108
+ quick: "quick";
109
109
  }>;
110
110
  }, z.core.$strict>, z.ZodObject<{
111
111
  argv: z.ZodArray<z.ZodString>;