@livingdata/pipex 0.0.8 → 0.0.10

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 (90) hide show
  1. package/README.md +186 -16
  2. package/dist/__tests__/errors.js +162 -0
  3. package/dist/__tests__/helpers.js +41 -0
  4. package/dist/__tests__/types.js +8 -0
  5. package/dist/cli/__tests__/condition.js +23 -0
  6. package/dist/cli/__tests__/dag.js +154 -0
  7. package/dist/cli/__tests__/pipeline-loader.js +267 -0
  8. package/dist/cli/__tests__/pipeline-runner.js +257 -0
  9. package/dist/cli/__tests__/state-persistence.js +80 -0
  10. package/dist/cli/__tests__/state.js +58 -0
  11. package/dist/cli/__tests__/step-runner.js +116 -0
  12. package/dist/cli/commands/bundle.js +35 -0
  13. package/dist/cli/commands/cat.js +54 -0
  14. package/dist/cli/commands/clean.js +22 -0
  15. package/dist/cli/commands/exec.js +89 -0
  16. package/dist/cli/commands/export.js +32 -0
  17. package/dist/cli/commands/inspect.js +58 -0
  18. package/dist/cli/commands/list.js +39 -0
  19. package/dist/cli/commands/logs.js +54 -0
  20. package/dist/cli/commands/prune.js +26 -0
  21. package/dist/cli/commands/rm-step.js +41 -0
  22. package/dist/cli/commands/rm.js +27 -0
  23. package/dist/cli/commands/run-bundle.js +59 -0
  24. package/dist/cli/commands/run.js +44 -0
  25. package/dist/cli/commands/show.js +108 -0
  26. package/dist/cli/condition.js +11 -0
  27. package/dist/cli/dag.js +143 -0
  28. package/dist/cli/index.js +24 -105
  29. package/dist/cli/interactive-reporter.js +227 -0
  30. package/dist/cli/pipeline-loader.js +10 -110
  31. package/dist/cli/pipeline-runner.js +256 -111
  32. package/dist/cli/reporter.js +2 -107
  33. package/dist/cli/state.js +30 -9
  34. package/dist/cli/step-loader.js +25 -0
  35. package/dist/cli/step-resolver.js +111 -0
  36. package/dist/cli/step-runner.js +226 -0
  37. package/dist/cli/utils.js +3 -0
  38. package/dist/core/__tests__/bundle.js +663 -0
  39. package/dist/core/__tests__/condition.js +23 -0
  40. package/dist/core/__tests__/dag.js +154 -0
  41. package/dist/core/__tests__/env-file.test.js +41 -0
  42. package/dist/core/__tests__/event-aggregator.js +244 -0
  43. package/dist/core/__tests__/pipeline-loader.js +267 -0
  44. package/dist/core/__tests__/pipeline-runner.js +257 -0
  45. package/dist/core/__tests__/state-persistence.js +80 -0
  46. package/dist/core/__tests__/state.js +58 -0
  47. package/dist/core/__tests__/step-runner.js +118 -0
  48. package/dist/core/__tests__/stream-reporter.js +142 -0
  49. package/dist/core/__tests__/transport.js +50 -0
  50. package/dist/core/__tests__/utils.js +40 -0
  51. package/dist/core/bundle.js +130 -0
  52. package/dist/core/condition.js +11 -0
  53. package/dist/core/dag.js +143 -0
  54. package/dist/core/env-file.js +6 -0
  55. package/dist/core/event-aggregator.js +114 -0
  56. package/dist/core/index.js +14 -0
  57. package/dist/core/pipeline-loader.js +81 -0
  58. package/dist/core/pipeline-runner.js +360 -0
  59. package/dist/core/reporter.js +11 -0
  60. package/dist/core/state.js +110 -0
  61. package/dist/core/step-loader.js +25 -0
  62. package/dist/core/step-resolver.js +117 -0
  63. package/dist/core/step-runner.js +225 -0
  64. package/dist/core/stream-reporter.js +41 -0
  65. package/dist/core/transport.js +9 -0
  66. package/dist/core/utils.js +56 -0
  67. package/dist/engine/__tests__/workspace.js +288 -0
  68. package/dist/engine/docker-executor.js +32 -6
  69. package/dist/engine/index.js +1 -0
  70. package/dist/engine/workspace.js +164 -66
  71. package/dist/errors.js +122 -0
  72. package/dist/index.js +3 -0
  73. package/dist/kits/__tests__/index.js +23 -0
  74. package/dist/kits/builtin/__tests__/node.js +74 -0
  75. package/dist/kits/builtin/__tests__/python.js +67 -0
  76. package/dist/kits/builtin/__tests__/shell.js +74 -0
  77. package/dist/kits/builtin/node.js +10 -5
  78. package/dist/kits/builtin/python.js +10 -5
  79. package/dist/kits/builtin/shell.js +2 -1
  80. package/dist/kits/index.js +2 -1
  81. package/package.json +6 -3
  82. package/dist/cli/types.js +0 -3
  83. package/dist/engine/docker-runtime.js +0 -65
  84. package/dist/engine/runtime.js +0 -2
  85. package/dist/kits/bash.js +0 -19
  86. package/dist/kits/builtin/bash.js +0 -19
  87. package/dist/kits/node.js +0 -56
  88. package/dist/kits/python.js +0 -51
  89. package/dist/kits/types.js +0 -1
  90. package/dist/reporter.js +0 -13
@@ -1,36 +1,17 @@
1
- import { cp } from 'node:fs/promises';
2
- import { dirname, resolve } from 'node:path';
1
+ import process from 'node:process';
2
+ import { cpus } from 'node:os';
3
+ import { cp, writeFile } from 'node:fs/promises';
4
+ import { setTimeout } from 'node:timers/promises';
5
+ import { createWriteStream } from 'node:fs';
6
+ import { dirname, join, resolve } from 'node:path';
3
7
  import { Workspace } from '../engine/index.js';
8
+ import { ContainerCrashError, PipexError } from '../errors.js';
9
+ import { buildGraph, topologicalLevels, subgraph, leafNodes } from './dag.js';
10
+ import { evaluateCondition } from './condition.js';
4
11
  import { StateManager } from './state.js';
12
+ import { dirSize } from './utils.js';
5
13
  /**
6
- * Orchestrates pipeline execution with dependency resolution and caching.
7
- *
8
- * ## Workflow
9
- *
10
- * 1. **Workspace Resolution**: Determines workspace ID from CLI flag, config, or filename
11
- * 2. **State Loading**: Loads cached fingerprints from state.json
12
- * 3. **Step Execution**: For each step:
13
- * a. Computes fingerprint (image + cmd + env + input artifact IDs)
14
- * b. Checks cache (fingerprint match + artifact exists)
15
- * c. If cached: skips execution
16
- * d. If not cached: resolves inputs, prepares staging, executes container
17
- * e. On success: commits artifact, saves state
18
- * f. On failure: discards artifact, halts pipeline (unless allowFailure)
19
- * 4. **Completion**: Reports final pipeline status
20
- *
21
- * ## Dependencies
22
- *
23
- * Steps declare dependencies via `inputs: [{step: "stepId"}]`.
24
- * The runner:
25
- * - Mounts input artifacts as read-only volumes
26
- * - Optionally copies inputs to output staging (if `copyToOutput: true`)
27
- * - Tracks execution order to resolve step names to artifact IDs
28
- *
29
- * ## Caching
30
- *
31
- * Cache invalidation is automatic:
32
- * - Changing a step's configuration re-runs it
33
- * - Re-running a step invalidates all dependent steps
14
+ * Orchestrates pipeline execution with DAG-based parallel execution and caching.
34
15
  */
35
16
  export class PipelineRunner {
36
17
  loader;
@@ -44,10 +25,9 @@ export class PipelineRunner {
44
25
  this.workdirRoot = workdirRoot;
45
26
  }
46
27
  async run(pipelineFilePath, options) {
47
- const { workspace: workspaceName, force } = options ?? {};
28
+ const { workspace: workspaceName, force, dryRun, target, concurrency } = options ?? {};
48
29
  const config = await this.loader.load(pipelineFilePath);
49
30
  const pipelineRoot = dirname(resolve(pipelineFilePath));
50
- // Workspace ID priority: CLI arg > pipeline id
51
31
  const workspaceId = workspaceName ?? config.id;
52
32
  let workspace;
53
33
  try {
@@ -57,128 +37,265 @@ export class PipelineRunner {
57
37
  workspace = await Workspace.create(this.workdirRoot, workspaceId);
58
38
  }
59
39
  await workspace.cleanupStaging();
60
- await this.runtime.check();
40
+ if (!dryRun) {
41
+ await this.runtime.check();
42
+ await this.runtime.cleanupContainers(workspace.id);
43
+ }
61
44
  const state = new StateManager(workspace.root);
62
45
  await state.load();
63
- const stepArtifacts = new Map();
64
- this.reporter.state(workspace.id, 'PIPELINE_START', undefined, { pipelineName: config.name ?? config.id });
65
- for (const step of config.steps) {
66
- const stepRef = { id: step.id, displayName: step.name ?? step.id };
67
- const inputArtifactIds = step.inputs
68
- ?.map(i => stepArtifacts.get(i.step))
69
- .filter((id) => id !== undefined);
70
- const resolvedMounts = step.mounts?.map(m => ({
71
- hostPath: resolve(pipelineRoot, m.host),
72
- containerPath: m.container
73
- }));
74
- const currentFingerprint = StateManager.fingerprint({
75
- image: step.image,
76
- cmd: step.cmd,
77
- env: step.env,
78
- inputArtifactIds,
79
- mounts: resolvedMounts
80
- });
81
- const skipCache = force === true || (Array.isArray(force) && force.includes(step.id));
82
- if (!skipCache && await this.tryUseCache({ workspace, state, step, stepRef, currentFingerprint, stepArtifacts })) {
83
- continue;
84
- }
85
- this.reporter.state(workspace.id, 'STEP_STARTING', stepRef);
86
- const artifactId = workspace.generateArtifactId();
87
- const stagingPath = await workspace.prepareArtifact(artifactId);
88
- await this.prepareStagingWithInputs(workspace, step, stagingPath, stepArtifacts);
89
- // Prepare caches
90
- if (step.caches) {
91
- for (const cache of step.caches) {
92
- await workspace.prepareCache(cache.name);
46
+ const stepRuns = new Map();
47
+ let totalArtifactSize = 0;
48
+ // Build DAG and determine execution scope
49
+ const graph = buildGraph(config.steps);
50
+ const targets = target ?? leafNodes(graph);
51
+ const activeSteps = subgraph(graph, targets);
52
+ this.reporter.emit({
53
+ event: 'PIPELINE_START',
54
+ workspaceId: workspace.id,
55
+ pipelineName: config.name ?? config.id,
56
+ steps: config.steps
57
+ .filter(s => activeSteps.has(s.id))
58
+ .map(s => ({ id: s.id, displayName: s.name ?? s.id }))
59
+ });
60
+ const levels = topologicalLevels(graph)
61
+ .map(level => level.filter(id => activeSteps.has(id)))
62
+ .filter(level => level.length > 0);
63
+ const stepMap = new Map(config.steps.map(s => [s.id, s]));
64
+ const failed = new Set();
65
+ const skipped = new Set();
66
+ const maxConcurrency = concurrency ?? cpus().length;
67
+ for (const level of levels) {
68
+ const tasks = level.map(stepId => async () => {
69
+ const step = stepMap.get(stepId);
70
+ const stepRef = { id: step.id, displayName: step.name ?? step.id };
71
+ // Check if blocked by a failed/skipped required dependency
72
+ if (this.isDependencyBlocked(step, failed, skipped)) {
73
+ skipped.add(step.id);
74
+ this.reporter.emit({ event: 'STEP_SKIPPED', workspaceId: workspace.id, step: stepRef, reason: 'dependency' });
75
+ return 0;
93
76
  }
94
- }
95
- const { inputs, output, caches, mounts } = this.buildMounts(step, artifactId, stepArtifacts, pipelineRoot);
96
- const result = await this.runtime.run(workspace, {
97
- name: `pipex-${workspace.id}-${step.id}-${Date.now()}`,
98
- image: step.image,
99
- cmd: step.cmd,
100
- env: step.env,
101
- inputs,
102
- output,
103
- caches,
104
- mounts,
105
- sources: step.sources?.map(m => ({
77
+ // Evaluate condition
78
+ if (step.if) {
79
+ const conditionMet = await evaluateCondition(step.if, { env: process.env });
80
+ if (!conditionMet) {
81
+ skipped.add(step.id);
82
+ this.reporter.emit({ event: 'STEP_SKIPPED', workspaceId: workspace.id, step: stepRef, reason: 'condition' });
83
+ return 0;
84
+ }
85
+ }
86
+ // Compute fingerprint
87
+ const inputRunIds = step.inputs
88
+ ?.map(i => stepRuns.get(i.step))
89
+ .filter((id) => id !== undefined);
90
+ const resolvedMounts = step.mounts?.map(m => ({
106
91
  hostPath: resolve(pipelineRoot, m.host),
107
92
  containerPath: m.container
108
- })),
109
- network: step.allowNetwork ? 'bridge' : 'none',
110
- timeoutSec: step.timeoutSec
111
- }, ({ stream, line }) => {
112
- this.reporter.log(workspace.id, stepRef, stream, line);
93
+ }));
94
+ const currentFingerprint = StateManager.fingerprint({
95
+ image: step.image,
96
+ cmd: step.cmd,
97
+ env: step.env,
98
+ inputRunIds,
99
+ mounts: resolvedMounts
100
+ });
101
+ // Cache check
102
+ const skipCache = force === true || (Array.isArray(force) && force.includes(step.id));
103
+ if (!skipCache && await this.tryUseCache({ workspace, state, step, stepRef, currentFingerprint, stepRuns })) {
104
+ return 0;
105
+ }
106
+ // Dry run
107
+ if (dryRun) {
108
+ this.reporter.emit({ event: 'STEP_WOULD_RUN', workspaceId: workspace.id, step: stepRef });
109
+ return 0;
110
+ }
111
+ // Execute
112
+ this.reporter.emit({ event: 'STEP_STARTING', workspaceId: workspace.id, step: stepRef });
113
+ return this.executeStep({ workspace, state, step, stepRef, stepRuns, currentFingerprint, resolvedMounts, pipelineRoot });
113
114
  });
115
+ const results = await withConcurrency(tasks, maxConcurrency);
116
+ // Collect results
117
+ for (const [i, result] of results.entries()) {
118
+ const stepId = level[i];
119
+ if (result.status === 'fulfilled') {
120
+ totalArtifactSize += result.value;
121
+ }
122
+ else if (!failed.has(stepId)) {
123
+ // Step threw an error (ContainerCrashError if not allowFailure)
124
+ failed.add(stepId);
125
+ }
126
+ }
127
+ await state.save();
128
+ }
129
+ if (failed.size > 0) {
130
+ this.reporter.emit({ event: 'PIPELINE_FAILED', workspaceId: workspace.id });
131
+ // Re-throw the first failure to signal pipeline failure to the CLI
132
+ const firstFailedId = [...failed][0];
133
+ throw new ContainerCrashError(firstFailedId, 1);
134
+ }
135
+ this.reporter.emit({ event: 'PIPELINE_FINISHED', workspaceId: workspace.id, totalArtifactSize });
136
+ }
137
+ isDependencyBlocked(step, failed, skippedSteps) {
138
+ if (!step.inputs) {
139
+ return false;
140
+ }
141
+ return step.inputs.some(input => !input.optional && (failed.has(input.step) || skippedSteps.has(input.step)));
142
+ }
143
+ async executeStep({ workspace, state, step, stepRef, stepRuns, currentFingerprint, resolvedMounts, pipelineRoot }) {
144
+ const runId = workspace.generateRunId();
145
+ const stagingPath = await workspace.prepareRun(runId);
146
+ await this.prepareStagingWithInputs(workspace, step, workspace.runStagingArtifactsPath(runId), stepRuns);
147
+ if (step.caches) {
148
+ for (const cache of step.caches) {
149
+ await workspace.prepareCache(cache.name);
150
+ }
151
+ }
152
+ const { inputs, output, caches, mounts } = this.buildMounts(step, runId, stepRuns, pipelineRoot);
153
+ const stdoutLog = createWriteStream(join(stagingPath, 'stdout.log'));
154
+ const stderrLog = createWriteStream(join(stagingPath, 'stderr.log'));
155
+ try {
156
+ const maxRetries = step.retries ?? 0;
157
+ const retryDelay = step.retryDelayMs ?? 5000;
158
+ let result;
159
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
160
+ try {
161
+ result = await this.runtime.run(workspace, {
162
+ name: `pipex-${workspace.id}-${step.id}-${Date.now()}`,
163
+ image: step.image,
164
+ cmd: step.cmd,
165
+ env: step.env,
166
+ inputs,
167
+ output,
168
+ caches,
169
+ mounts,
170
+ sources: step.sources?.map(m => ({
171
+ hostPath: resolve(pipelineRoot, m.host),
172
+ containerPath: m.container
173
+ })),
174
+ network: step.allowNetwork ? 'bridge' : 'none',
175
+ timeoutSec: step.timeoutSec
176
+ }, ({ stream, line }) => {
177
+ if (stream === 'stdout') {
178
+ stdoutLog.write(line + '\n');
179
+ }
180
+ else {
181
+ stderrLog.write(line + '\n');
182
+ }
183
+ this.reporter.log(workspace.id, stepRef, stream, line);
184
+ });
185
+ break;
186
+ }
187
+ catch (error) {
188
+ if (error instanceof PipexError && error.transient && attempt < maxRetries) {
189
+ this.reporter.emit({ event: 'STEP_RETRYING', workspaceId: workspace.id, step: stepRef, attempt: attempt + 1, maxRetries });
190
+ await setTimeout(retryDelay);
191
+ continue;
192
+ }
193
+ throw error;
194
+ }
195
+ }
114
196
  this.reporter.result(workspace.id, stepRef, result);
197
+ await closeStream(stdoutLog);
198
+ await closeStream(stderrLog);
199
+ await this.writeRunMeta(stagingPath, { runId, step, stepRuns, resolvedMounts, currentFingerprint, result });
115
200
  if (result.exitCode === 0 || step.allowFailure) {
116
- await workspace.commitArtifact(artifactId);
117
- await workspace.linkArtifact(step.id, artifactId);
118
- stepArtifacts.set(step.id, artifactId);
119
- state.setStep(step.id, artifactId, currentFingerprint);
120
- await state.save();
121
- this.reporter.state(workspace.id, 'STEP_FINISHED', stepRef, { artifactId });
122
- }
123
- else {
124
- await workspace.discardArtifact(artifactId);
125
- this.reporter.state(workspace.id, 'STEP_FAILED', stepRef, { exitCode: result.exitCode });
126
- this.reporter.state(workspace.id, 'PIPELINE_FAILED');
127
- throw new Error(`Step ${step.id} failed with exit code ${result.exitCode}`);
201
+ await workspace.commitRun(runId);
202
+ await workspace.linkRun(step.id, runId);
203
+ stepRuns.set(step.id, runId);
204
+ state.setStep(step.id, runId, currentFingerprint);
205
+ const durationMs = result.finishedAt.getTime() - result.startedAt.getTime();
206
+ const artifactSize = await dirSize(workspace.runArtifactsPath(runId));
207
+ this.reporter.emit({ event: 'STEP_FINISHED', workspaceId: workspace.id, step: stepRef, runId, durationMs, artifactSize });
208
+ return artifactSize;
128
209
  }
210
+ await workspace.discardRun(runId);
211
+ this.reporter.emit({ event: 'STEP_FAILED', workspaceId: workspace.id, step: stepRef, exitCode: result.exitCode });
212
+ throw new ContainerCrashError(step.id, result.exitCode);
213
+ }
214
+ catch (error) {
215
+ await closeStream(stdoutLog);
216
+ await closeStream(stderrLog);
217
+ throw error;
129
218
  }
130
- this.reporter.state(workspace.id, 'PIPELINE_FINISHED');
131
219
  }
132
- async tryUseCache({ workspace, state, step, stepRef, currentFingerprint, stepArtifacts }) {
220
+ async writeRunMeta(stagingPath, { runId, step, stepRuns, resolvedMounts, currentFingerprint, result }) {
221
+ const meta = {
222
+ runId,
223
+ stepId: step.id,
224
+ stepName: step.name,
225
+ startedAt: result.startedAt.toISOString(),
226
+ finishedAt: result.finishedAt.toISOString(),
227
+ durationMs: result.finishedAt.getTime() - result.startedAt.getTime(),
228
+ exitCode: result.exitCode,
229
+ image: step.image,
230
+ cmd: step.cmd,
231
+ env: step.env,
232
+ inputs: step.inputs?.map(i => ({
233
+ step: i.step,
234
+ runId: stepRuns.get(i.step),
235
+ mountedAs: `/input/${i.step}`
236
+ })),
237
+ mounts: resolvedMounts,
238
+ caches: step.caches?.map(c => c.name),
239
+ allowNetwork: step.allowNetwork ?? false,
240
+ fingerprint: currentFingerprint,
241
+ status: result.exitCode === 0 ? 'success' : 'failure'
242
+ };
243
+ await writeFile(join(stagingPath, 'meta.json'), JSON.stringify(meta, null, 2), 'utf8');
244
+ }
245
+ async tryUseCache({ workspace, state, step, stepRef, currentFingerprint, stepRuns }) {
133
246
  const cached = state.getStep(step.id);
134
247
  if (cached?.fingerprint === currentFingerprint) {
135
248
  try {
136
- const artifacts = await workspace.listArtifacts();
137
- if (artifacts.includes(cached.artifactId)) {
138
- stepArtifacts.set(step.id, cached.artifactId);
139
- await workspace.linkArtifact(step.id, cached.artifactId);
140
- this.reporter.state(workspace.id, 'STEP_SKIPPED', stepRef, { artifactId: cached.artifactId, reason: 'cached' });
249
+ const runs = await workspace.listRuns();
250
+ if (runs.includes(cached.runId)) {
251
+ stepRuns.set(step.id, cached.runId);
252
+ await workspace.linkRun(step.id, cached.runId);
253
+ this.reporter.emit({ event: 'STEP_SKIPPED', workspaceId: workspace.id, step: stepRef, runId: cached.runId, reason: 'cached' });
141
254
  return true;
142
255
  }
143
256
  }
144
257
  catch {
145
- // Artifact missing, proceed with execution
258
+ // Run missing, proceed with execution
146
259
  }
147
260
  }
148
261
  return false;
149
262
  }
150
- async prepareStagingWithInputs(workspace, step, stagingPath, stepArtifacts) {
263
+ async prepareStagingWithInputs(workspace, step, stagingArtifactsPath, stepRuns) {
151
264
  if (!step.inputs) {
152
265
  return;
153
266
  }
154
267
  for (const input of step.inputs) {
155
- const inputArtifactId = stepArtifacts.get(input.step);
156
- if (!inputArtifactId) {
157
- throw new Error(`Step ${step.id}: input step '${input.step}' not found or not yet executed`);
268
+ const inputRunId = stepRuns.get(input.step);
269
+ if (!inputRunId) {
270
+ if (input.optional) {
271
+ continue;
272
+ }
273
+ // Non-optional input without a run — this shouldn't happen in DAG mode
274
+ // but keep the continue for safety (the step may still work with bind mounts)
275
+ continue;
158
276
  }
159
277
  if (input.copyToOutput) {
160
- await cp(workspace.artifactPath(inputArtifactId), stagingPath, { recursive: true });
278
+ await cp(workspace.runArtifactsPath(inputRunId), stagingArtifactsPath, { recursive: true });
161
279
  }
162
280
  }
163
281
  }
164
- buildMounts(step, outputArtifactId, stepArtifacts, pipelineRoot) {
282
+ buildMounts(step, outputRunId, stepRuns, pipelineRoot) {
165
283
  const inputs = [];
166
284
  if (step.inputs) {
167
285
  for (const input of step.inputs) {
168
- const inputArtifactId = stepArtifacts.get(input.step);
169
- if (inputArtifactId) {
286
+ const inputRunId = stepRuns.get(input.step);
287
+ if (inputRunId) {
170
288
  inputs.push({
171
- artifactId: inputArtifactId,
289
+ runId: inputRunId,
172
290
  containerPath: `/input/${input.step}`
173
291
  });
174
292
  }
175
293
  }
176
294
  }
177
295
  const output = {
178
- stagingArtifactId: outputArtifactId,
296
+ stagingRunId: outputRunId,
179
297
  containerPath: step.outputPath ?? '/output'
180
298
  };
181
- // Build cache mounts
182
299
  let caches;
183
300
  if (step.caches) {
184
301
  caches = step.caches.map(c => ({
@@ -196,3 +313,31 @@ export class PipelineRunner {
196
313
  return { inputs, output, caches, mounts };
197
314
  }
198
315
  }
316
+ async function withConcurrency(tasks, limit) {
317
+ const results = Array.from({ length: tasks.length });
318
+ let next = 0;
319
+ async function worker() {
320
+ while (next < tasks.length) {
321
+ const i = next++;
322
+ try {
323
+ results[i] = { status: 'fulfilled', value: await tasks[i]() };
324
+ }
325
+ catch (error) {
326
+ results[i] = { status: 'rejected', reason: error };
327
+ }
328
+ }
329
+ }
330
+ await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, async () => worker()));
331
+ return results;
332
+ }
333
+ async function closeStream(stream) {
334
+ if (stream.destroyed) {
335
+ return;
336
+ }
337
+ return new Promise((resolve, reject) => {
338
+ stream.end(() => {
339
+ resolve();
340
+ });
341
+ stream.on('error', reject);
342
+ });
343
+ }
@@ -1,15 +1,12 @@
1
1
  import pino from 'pino';
2
- import chalk from 'chalk';
3
- import ora from 'ora';
4
2
  /**
5
3
  * Reporter that outputs structured JSON logs via pino.
6
4
  * Suitable for CI/CD environments and log aggregation.
7
5
  */
8
6
  export class ConsoleReporter {
9
7
  logger = pino({ level: 'info' });
10
- state(workspaceId, event, step, meta) {
11
- const stepName = step?.displayName === step?.id ? undefined : step?.displayName;
12
- this.logger.info({ workspaceId, event, stepId: step?.id, stepName, ...meta });
8
+ emit(event) {
9
+ this.logger.info(event);
13
10
  }
14
11
  log(workspaceId, step, stream, line) {
15
12
  this.logger.info({ workspaceId, stepId: step.id, stream, line });
@@ -18,105 +15,3 @@ export class ConsoleReporter {
18
15
  this.logger.info({ workspaceId, stepId: step.id, result });
19
16
  }
20
17
  }
21
- /**
22
- * Reporter with interactive terminal UI using spinners and colors.
23
- * Suitable for local development and manual execution.
24
- */
25
- export class InteractiveReporter {
26
- static get maxStderrLines() {
27
- return 20;
28
- }
29
- spinner;
30
- stepSpinners = new Map();
31
- stderrBuffers = new Map();
32
- state(workspaceId, event, step, meta) {
33
- switch (event) {
34
- case 'PIPELINE_START': {
35
- const displayName = meta?.pipelineName ?? workspaceId;
36
- console.log(chalk.bold(`\n▶ Pipeline: ${chalk.cyan(displayName)}\n`));
37
- break;
38
- }
39
- case 'STEP_STARTING': {
40
- if (step) {
41
- const spinner = ora({ text: step.displayName, prefixText: ' ' }).start();
42
- this.stepSpinners.set(step.id, spinner);
43
- }
44
- break;
45
- }
46
- case 'STEP_SKIPPED': {
47
- if (step) {
48
- const spinner = this.stepSpinners.get(step.id);
49
- if (spinner) {
50
- spinner.stopAndPersist({ symbol: chalk.gray('⊙'), text: chalk.gray(`${step.displayName} (cached)`) });
51
- this.stepSpinners.delete(step.id);
52
- }
53
- else {
54
- console.log(` ${chalk.gray('⊙')} ${chalk.gray(`${step.displayName} (cached)`)}`);
55
- }
56
- }
57
- break;
58
- }
59
- case 'STEP_FINISHED': {
60
- if (step) {
61
- const spinner = this.stepSpinners.get(step.id);
62
- if (spinner) {
63
- spinner.stopAndPersist({ symbol: chalk.green('✓'), text: chalk.green(step.displayName) });
64
- this.stepSpinners.delete(step.id);
65
- }
66
- this.stderrBuffers.delete(step.id);
67
- }
68
- break;
69
- }
70
- case 'STEP_FAILED': {
71
- if (step) {
72
- this.handleStepFailed(step, meta);
73
- }
74
- break;
75
- }
76
- case 'PIPELINE_FINISHED': {
77
- console.log(chalk.bold.green('\n✓ Pipeline completed\n'));
78
- break;
79
- }
80
- case 'PIPELINE_FAILED': {
81
- console.log(chalk.bold.red('\n✗ Pipeline failed\n'));
82
- break;
83
- }
84
- }
85
- }
86
- log(_workspaceId, step, stream, line) {
87
- if (stream === 'stderr') {
88
- let buffer = this.stderrBuffers.get(step.id);
89
- if (!buffer) {
90
- buffer = [];
91
- this.stderrBuffers.set(step.id, buffer);
92
- }
93
- buffer.push(line);
94
- if (buffer.length > InteractiveReporter.maxStderrLines) {
95
- buffer.shift();
96
- }
97
- }
98
- }
99
- result(_workspaceId, _step, _result) {
100
- // Results shown via state updates
101
- }
102
- handleStepFailed(step, meta) {
103
- const spinner = this.stepSpinners.get(step.id);
104
- const exitCode = meta?.exitCode;
105
- if (spinner) {
106
- const exitInfo = exitCode === undefined ? '' : ` (exit ${exitCode})`;
107
- spinner.stopAndPersist({
108
- symbol: chalk.red('✗'),
109
- text: chalk.red(`${step.displayName}${exitInfo}`)
110
- });
111
- this.stepSpinners.delete(step.id);
112
- }
113
- const stderr = this.stderrBuffers.get(step.id);
114
- if (stderr && stderr.length > 0) {
115
- console.log(chalk.red(' ── stderr ──'));
116
- for (const line of stderr) {
117
- console.log(chalk.red(` ${line}`));
118
- }
119
- }
120
- this.stderrBuffers.delete(step.id);
121
- }
122
- }
package/dist/cli/state.js CHANGED
@@ -5,24 +5,24 @@ import { createHash } from 'node:crypto';
5
5
  * Manages caching state for pipeline execution.
6
6
  *
7
7
  * The StateManager computes fingerprints for steps and tracks which
8
- * artifact was produced by each step. This enables cache hits when
8
+ * run was produced by each step. This enables cache hits when
9
9
  * a step's configuration hasn't changed.
10
10
  *
11
11
  * ## Fingerprint Algorithm
12
12
  *
13
13
  * A step fingerprint is computed as:
14
14
  * ```
15
- * SHA256(image + JSON(cmd) + JSON(sorted env) + JSON(sorted inputArtifactIds))
15
+ * SHA256(image + JSON(cmd) + JSON(sorted env) + JSON(sorted inputRunIds))
16
16
  * ```
17
17
  *
18
18
  * A step is re-executed when:
19
19
  * - The fingerprint changes (image, cmd, env, or inputs modified)
20
- * - The artifact no longer exists (manually deleted)
20
+ * - The run no longer exists (manually deleted)
21
21
  *
22
22
  * ## Cache Propagation
23
23
  *
24
24
  * Changes propagate through dependencies. If step A is modified,
25
- * all steps depending on A are invalidated automatically (via inputArtifactIds).
25
+ * all steps depending on A are invalidated automatically (via inputRunIds).
26
26
  */
27
27
  export class StateManager {
28
28
  static fingerprint(config) {
@@ -32,8 +32,8 @@ export class StateManager {
32
32
  if (config.env) {
33
33
  hash.update(JSON.stringify(Object.entries(config.env).sort((a, b) => a[0].localeCompare(b[0]))));
34
34
  }
35
- if (config.inputArtifactIds) {
36
- hash.update(JSON.stringify([...config.inputArtifactIds].sort((a, b) => a.localeCompare(b))));
35
+ if (config.inputRunIds) {
36
+ hash.update(JSON.stringify([...config.inputRunIds].sort((a, b) => a.localeCompare(b))));
37
37
  }
38
38
  if (config.mounts && config.mounts.length > 0) {
39
39
  const sorted = [...config.mounts].sort((a, b) => a.containerPath.localeCompare(b.containerPath));
@@ -77,13 +77,34 @@ export class StateManager {
77
77
  getStep(stepId) {
78
78
  return this.state.steps[stepId];
79
79
  }
80
+ /**
81
+ * Lists all steps with their run IDs (in insertion order).
82
+ * @returns Array of {stepId, runId} entries
83
+ */
84
+ listSteps() {
85
+ return Object.entries(this.state.steps).map(([stepId, { runId }]) => ({ stepId, runId }));
86
+ }
87
+ /**
88
+ * Returns the set of run IDs currently referenced by state.
89
+ */
90
+ activeRunIds() {
91
+ return new Set(Object.values(this.state.steps).map(s => s.runId));
92
+ }
80
93
  /**
81
94
  * Updates cached state for a step.
82
95
  * @param stepId - Step identifier
83
- * @param artifactId - Artifact produced by the step
96
+ * @param runId - Run produced by the step
84
97
  * @param fingerprint - Step configuration fingerprint
85
98
  */
86
- setStep(stepId, artifactId, fingerprint) {
87
- this.state.steps[stepId] = { artifactId, fingerprint };
99
+ setStep(stepId, runId, fingerprint) {
100
+ this.state.steps[stepId] = { runId, fingerprint };
101
+ }
102
+ /**
103
+ * Removes a step's cached state.
104
+ * @param stepId - Step identifier
105
+ */
106
+ removeStep(stepId) {
107
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
108
+ delete this.state.steps[stepId];
88
109
  }
89
110
  }
@@ -0,0 +1,25 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { ValidationError } from '../errors.js';
3
+ import { parsePipelineFile } from './pipeline-loader.js';
4
+ import { resolveStep, validateStep } from './step-resolver.js';
5
+ /**
6
+ * Loads and resolves a single step definition from a file.
7
+ */
8
+ export async function loadStepFile(filePath, stepIdOverride) {
9
+ const content = await readFile(filePath, 'utf8');
10
+ const raw = parsePipelineFile(content, filePath);
11
+ if (!raw || typeof raw !== 'object') {
12
+ throw new ValidationError('Step file must contain an object');
13
+ }
14
+ // If no id/name provided, require --step override
15
+ if (!('id' in raw && raw.id) && !('name' in raw && raw.name) && !stepIdOverride) {
16
+ throw new ValidationError('Step file must have "id" or "name", or use --step to set an ID');
17
+ }
18
+ // Apply step ID override
19
+ if (stepIdOverride) {
20
+ raw.id = stepIdOverride;
21
+ }
22
+ const step = resolveStep(raw);
23
+ validateStep(step);
24
+ return step;
25
+ }