@livingdata/pipex 0.0.9 → 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 (88) hide show
  1. package/README.md +154 -14
  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/exec.js +89 -0
  15. package/dist/cli/commands/export.js +2 -2
  16. package/dist/cli/commands/inspect.js +1 -1
  17. package/dist/cli/commands/list.js +2 -1
  18. package/dist/cli/commands/logs.js +1 -1
  19. package/dist/cli/commands/prune.js +1 -1
  20. package/dist/cli/commands/rm-step.js +41 -0
  21. package/dist/cli/commands/run-bundle.js +59 -0
  22. package/dist/cli/commands/run.js +9 -4
  23. package/dist/cli/commands/show.js +42 -7
  24. package/dist/cli/condition.js +11 -0
  25. package/dist/cli/dag.js +143 -0
  26. package/dist/cli/index.js +6 -0
  27. package/dist/cli/interactive-reporter.js +227 -0
  28. package/dist/cli/pipeline-loader.js +10 -110
  29. package/dist/cli/pipeline-runner.js +164 -78
  30. package/dist/cli/reporter.js +2 -158
  31. package/dist/cli/state.js +8 -0
  32. package/dist/cli/step-loader.js +25 -0
  33. package/dist/cli/step-resolver.js +111 -0
  34. package/dist/cli/step-runner.js +226 -0
  35. package/dist/cli/utils.js +0 -46
  36. package/dist/core/__tests__/bundle.js +663 -0
  37. package/dist/core/__tests__/condition.js +23 -0
  38. package/dist/core/__tests__/dag.js +154 -0
  39. package/dist/core/__tests__/env-file.test.js +41 -0
  40. package/dist/core/__tests__/event-aggregator.js +244 -0
  41. package/dist/core/__tests__/pipeline-loader.js +267 -0
  42. package/dist/core/__tests__/pipeline-runner.js +257 -0
  43. package/dist/core/__tests__/state-persistence.js +80 -0
  44. package/dist/core/__tests__/state.js +58 -0
  45. package/dist/core/__tests__/step-runner.js +118 -0
  46. package/dist/core/__tests__/stream-reporter.js +142 -0
  47. package/dist/core/__tests__/transport.js +50 -0
  48. package/dist/core/__tests__/utils.js +40 -0
  49. package/dist/core/bundle.js +130 -0
  50. package/dist/core/condition.js +11 -0
  51. package/dist/core/dag.js +143 -0
  52. package/dist/core/env-file.js +6 -0
  53. package/dist/core/event-aggregator.js +114 -0
  54. package/dist/core/index.js +14 -0
  55. package/dist/core/pipeline-loader.js +81 -0
  56. package/dist/core/pipeline-runner.js +360 -0
  57. package/dist/core/reporter.js +11 -0
  58. package/dist/core/state.js +110 -0
  59. package/dist/core/step-loader.js +25 -0
  60. package/dist/core/step-resolver.js +117 -0
  61. package/dist/core/step-runner.js +225 -0
  62. package/dist/core/stream-reporter.js +41 -0
  63. package/dist/core/transport.js +9 -0
  64. package/dist/core/utils.js +56 -0
  65. package/dist/engine/__tests__/workspace.js +288 -0
  66. package/dist/engine/docker-executor.js +10 -2
  67. package/dist/engine/index.js +1 -0
  68. package/dist/engine/workspace.js +76 -12
  69. package/dist/errors.js +122 -0
  70. package/dist/index.js +3 -0
  71. package/dist/kits/__tests__/index.js +23 -0
  72. package/dist/kits/builtin/__tests__/node.js +74 -0
  73. package/dist/kits/builtin/__tests__/python.js +67 -0
  74. package/dist/kits/builtin/__tests__/shell.js +74 -0
  75. package/dist/kits/builtin/node.js +10 -5
  76. package/dist/kits/builtin/python.js +10 -5
  77. package/dist/kits/builtin/shell.js +2 -1
  78. package/dist/kits/index.js +2 -1
  79. package/package.json +6 -3
  80. package/dist/cli/types.js +0 -3
  81. package/dist/engine/docker-runtime.js +0 -65
  82. package/dist/engine/runtime.js +0 -2
  83. package/dist/kits/bash.js +0 -19
  84. package/dist/kits/builtin/bash.js +0 -19
  85. package/dist/kits/node.js +0 -56
  86. package/dist/kits/python.js +0 -51
  87. package/dist/kits/types.js +0 -1
  88. package/dist/reporter.js +0 -13
@@ -1,31 +1,17 @@
1
+ import process from 'node:process';
2
+ import { cpus } from 'node:os';
1
3
  import { cp, writeFile } from 'node:fs/promises';
4
+ import { setTimeout } from 'node:timers/promises';
2
5
  import { createWriteStream } from 'node:fs';
3
6
  import { dirname, join, resolve } from 'node:path';
4
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';
5
11
  import { StateManager } from './state.js';
6
12
  import { dirSize } from './utils.js';
7
13
  /**
8
- * Orchestrates pipeline execution with dependency resolution and caching.
9
- *
10
- * ## Workflow
11
- *
12
- * 1. **Workspace Resolution**: Determines workspace ID from CLI flag, config, or filename
13
- * 2. **State Loading**: Loads cached fingerprints from state.json
14
- * 3. **Step Execution**: For each step:
15
- * a. Computes fingerprint (image + cmd + env + input run IDs)
16
- * b. Checks cache (fingerprint match + run exists)
17
- * c. If cached: skips execution
18
- * d. If not cached: resolves inputs, prepares staging, executes container
19
- * e. On success: writes meta.json, commits run, saves state
20
- * f. On failure: discards run, halts pipeline (unless allowFailure)
21
- * 4. **Completion**: Reports final pipeline status
22
- *
23
- * ## Runs
24
- *
25
- * Each step execution produces a **run** containing:
26
- * - `artifacts/` — files produced by the step
27
- * - `stdout.log` / `stderr.log` — captured container logs
28
- * - `meta.json` — structured execution metadata
14
+ * Orchestrates pipeline execution with DAG-based parallel execution and caching.
29
15
  */
30
16
  export class PipelineRunner {
31
17
  loader;
@@ -39,7 +25,7 @@ export class PipelineRunner {
39
25
  this.workdirRoot = workdirRoot;
40
26
  }
41
27
  async run(pipelineFilePath, options) {
42
- const { workspace: workspaceName, force, dryRun } = options ?? {};
28
+ const { workspace: workspaceName, force, dryRun, target, concurrency } = options ?? {};
43
29
  const config = await this.loader.load(pipelineFilePath);
44
30
  const pipelineRoot = dirname(resolve(pipelineFilePath));
45
31
  const workspaceId = workspaceName ?? config.id;
@@ -59,36 +45,100 @@ export class PipelineRunner {
59
45
  await state.load();
60
46
  const stepRuns = new Map();
61
47
  let totalArtifactSize = 0;
62
- this.reporter.state(workspace.id, 'PIPELINE_START', undefined, { pipelineName: config.name ?? config.id });
63
- for (const step of config.steps) {
64
- const stepRef = { id: step.id, displayName: step.name ?? step.id };
65
- const inputRunIds = step.inputs
66
- ?.map(i => stepRuns.get(i.step))
67
- .filter((id) => id !== undefined);
68
- const resolvedMounts = step.mounts?.map(m => ({
69
- hostPath: resolve(pipelineRoot, m.host),
70
- containerPath: m.container
71
- }));
72
- const currentFingerprint = StateManager.fingerprint({
73
- image: step.image,
74
- cmd: step.cmd,
75
- env: step.env,
76
- inputRunIds,
77
- mounts: resolvedMounts
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;
76
+ }
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 => ({
91
+ hostPath: resolve(pipelineRoot, m.host),
92
+ containerPath: m.container
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 });
78
114
  });
79
- const skipCache = force === true || (Array.isArray(force) && force.includes(step.id));
80
- if (!skipCache && await this.tryUseCache({ workspace, state, step, stepRef, currentFingerprint, stepRuns })) {
81
- continue;
82
- }
83
- if (dryRun) {
84
- this.reporter.state(workspace.id, 'STEP_WOULD_RUN', stepRef);
85
- continue;
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
+ }
86
126
  }
87
- this.reporter.state(workspace.id, 'STEP_STARTING', stepRef);
88
- const stepArtifactSize = await this.executeStep({ workspace, state, step, stepRef, stepRuns, currentFingerprint, resolvedMounts, pipelineRoot });
89
- totalArtifactSize += stepArtifactSize;
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);
90
134
  }
91
- this.reporter.state(workspace.id, 'PIPELINE_FINISHED', undefined, { totalArtifactSize });
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)));
92
142
  }
93
143
  async executeStep({ workspace, state, step, stepRef, stepRuns, currentFingerprint, resolvedMounts, pipelineRoot }) {
94
144
  const runId = workspace.generateRunId();
@@ -103,30 +153,46 @@ export class PipelineRunner {
103
153
  const stdoutLog = createWriteStream(join(stagingPath, 'stdout.log'));
104
154
  const stderrLog = createWriteStream(join(stagingPath, 'stderr.log'));
105
155
  try {
106
- const result = await this.runtime.run(workspace, {
107
- name: `pipex-${workspace.id}-${step.id}-${Date.now()}`,
108
- image: step.image,
109
- cmd: step.cmd,
110
- env: step.env,
111
- inputs,
112
- output,
113
- caches,
114
- mounts,
115
- sources: step.sources?.map(m => ({
116
- hostPath: resolve(pipelineRoot, m.host),
117
- containerPath: m.container
118
- })),
119
- network: step.allowNetwork ? 'bridge' : 'none',
120
- timeoutSec: step.timeoutSec
121
- }, ({ stream, line }) => {
122
- if (stream === 'stdout') {
123
- stdoutLog.write(line + '\n');
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;
124
186
  }
125
- else {
126
- stderrLog.write(line + '\n');
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;
127
194
  }
128
- this.reporter.log(workspace.id, stepRef, stream, line);
129
- });
195
+ }
130
196
  this.reporter.result(workspace.id, stepRef, result);
131
197
  await closeStream(stdoutLog);
132
198
  await closeStream(stderrLog);
@@ -136,16 +202,14 @@ export class PipelineRunner {
136
202
  await workspace.linkRun(step.id, runId);
137
203
  stepRuns.set(step.id, runId);
138
204
  state.setStep(step.id, runId, currentFingerprint);
139
- await state.save();
140
205
  const durationMs = result.finishedAt.getTime() - result.startedAt.getTime();
141
206
  const artifactSize = await dirSize(workspace.runArtifactsPath(runId));
142
- this.reporter.state(workspace.id, 'STEP_FINISHED', stepRef, { runId, durationMs, artifactSize });
207
+ this.reporter.emit({ event: 'STEP_FINISHED', workspaceId: workspace.id, step: stepRef, runId, durationMs, artifactSize });
143
208
  return artifactSize;
144
209
  }
145
210
  await workspace.discardRun(runId);
146
- this.reporter.state(workspace.id, 'STEP_FAILED', stepRef, { exitCode: result.exitCode });
147
- this.reporter.state(workspace.id, 'PIPELINE_FAILED');
148
- throw new Error(`Step ${step.id} failed with exit code ${result.exitCode}`);
211
+ this.reporter.emit({ event: 'STEP_FAILED', workspaceId: workspace.id, step: stepRef, exitCode: result.exitCode });
212
+ throw new ContainerCrashError(step.id, result.exitCode);
149
213
  }
150
214
  catch (error) {
151
215
  await closeStream(stdoutLog);
@@ -186,7 +250,7 @@ export class PipelineRunner {
186
250
  if (runs.includes(cached.runId)) {
187
251
  stepRuns.set(step.id, cached.runId);
188
252
  await workspace.linkRun(step.id, cached.runId);
189
- this.reporter.state(workspace.id, 'STEP_SKIPPED', stepRef, { runId: cached.runId, reason: 'cached' });
253
+ this.reporter.emit({ event: 'STEP_SKIPPED', workspaceId: workspace.id, step: stepRef, runId: cached.runId, reason: 'cached' });
190
254
  return true;
191
255
  }
192
256
  }
@@ -203,7 +267,12 @@ export class PipelineRunner {
203
267
  for (const input of step.inputs) {
204
268
  const inputRunId = stepRuns.get(input.step);
205
269
  if (!inputRunId) {
206
- throw new Error(`Step ${step.id}: input step '${input.step}' not found or not yet executed`);
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;
207
276
  }
208
277
  if (input.copyToOutput) {
209
278
  await cp(workspace.runArtifactsPath(inputRunId), stagingArtifactsPath, { recursive: true });
@@ -244,6 +313,23 @@ export class PipelineRunner {
244
313
  return { inputs, output, caches, mounts };
245
314
  }
246
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
+ }
247
333
  async function closeStream(stream) {
248
334
  if (stream.destroyed) {
249
335
  return;
@@ -1,16 +1,12 @@
1
1
  import pino from 'pino';
2
- import chalk from 'chalk';
3
- import ora from 'ora';
4
- import { formatDuration, formatSize } from './utils.js';
5
2
  /**
6
3
  * Reporter that outputs structured JSON logs via pino.
7
4
  * Suitable for CI/CD environments and log aggregation.
8
5
  */
9
6
  export class ConsoleReporter {
10
7
  logger = pino({ level: 'info' });
11
- state(workspaceId, event, step, meta) {
12
- const stepName = step?.displayName === step?.id ? undefined : step?.displayName;
13
- this.logger.info({ workspaceId, event, stepId: step?.id, stepName, ...meta });
8
+ emit(event) {
9
+ this.logger.info(event);
14
10
  }
15
11
  log(workspaceId, step, stream, line) {
16
12
  this.logger.info({ workspaceId, stepId: step.id, stream, line });
@@ -19,155 +15,3 @@ export class ConsoleReporter {
19
15
  this.logger.info({ workspaceId, stepId: step.id, result });
20
16
  }
21
17
  }
22
- /**
23
- * Reporter with interactive terminal UI using spinners and colors.
24
- * Suitable for local development and manual execution.
25
- */
26
- export class InteractiveReporter {
27
- static get maxStderrLines() {
28
- return 20;
29
- }
30
- verbose;
31
- spinner;
32
- stepSpinners = new Map();
33
- stderrBuffers = new Map();
34
- constructor(options) {
35
- this.verbose = options?.verbose ?? false;
36
- }
37
- state(workspaceId, event, step, meta) {
38
- switch (event) {
39
- case 'PIPELINE_START': {
40
- const displayName = meta?.pipelineName ?? workspaceId;
41
- console.log(chalk.bold(`\n▶ Pipeline: ${chalk.cyan(displayName)}\n`));
42
- break;
43
- }
44
- case 'STEP_STARTING': {
45
- if (step) {
46
- const spinner = ora({ text: step.displayName, prefixText: ' ' }).start();
47
- this.stepSpinners.set(step.id, spinner);
48
- }
49
- break;
50
- }
51
- case 'STEP_SKIPPED': {
52
- if (step) {
53
- const spinner = this.stepSpinners.get(step.id);
54
- if (spinner) {
55
- spinner.stopAndPersist({ symbol: chalk.gray('⊙'), text: chalk.gray(`${step.displayName} (cached)`) });
56
- this.stepSpinners.delete(step.id);
57
- }
58
- else {
59
- console.log(` ${chalk.gray('⊙')} ${chalk.gray(`${step.displayName} (cached)`)}`);
60
- }
61
- }
62
- break;
63
- }
64
- case 'STEP_FINISHED': {
65
- if (step) {
66
- this.handleStepFinished(step, meta);
67
- }
68
- break;
69
- }
70
- case 'STEP_FAILED': {
71
- if (step) {
72
- this.handleStepFailed(step, meta);
73
- }
74
- break;
75
- }
76
- case 'STEP_WOULD_RUN': {
77
- if (step) {
78
- this.handleStepWouldRun(step);
79
- }
80
- break;
81
- }
82
- case 'PIPELINE_FINISHED': {
83
- this.handlePipelineFinished(meta);
84
- break;
85
- }
86
- case 'PIPELINE_FAILED': {
87
- console.log(chalk.bold.red('\n✗ Pipeline failed\n'));
88
- break;
89
- }
90
- }
91
- }
92
- log(_workspaceId, step, stream, line) {
93
- if (this.verbose) {
94
- const spinner = this.stepSpinners.get(step.id);
95
- const prefix = chalk.gray(` [${step.id}]`);
96
- if (spinner) {
97
- spinner.clear();
98
- console.log(`${prefix} ${line}`);
99
- spinner.render();
100
- }
101
- else {
102
- console.log(`${prefix} ${line}`);
103
- }
104
- }
105
- if (stream === 'stderr') {
106
- let buffer = this.stderrBuffers.get(step.id);
107
- if (!buffer) {
108
- buffer = [];
109
- this.stderrBuffers.set(step.id, buffer);
110
- }
111
- buffer.push(line);
112
- if (buffer.length > InteractiveReporter.maxStderrLines) {
113
- buffer.shift();
114
- }
115
- }
116
- }
117
- result(_workspaceId, _step, _result) {
118
- // Results shown via state updates
119
- }
120
- handleStepFinished(step, meta) {
121
- const spinner = this.stepSpinners.get(step.id);
122
- if (spinner) {
123
- const details = [];
124
- if (typeof meta?.durationMs === 'number') {
125
- details.push(formatDuration(meta.durationMs));
126
- }
127
- if (typeof meta?.artifactSize === 'number' && meta.artifactSize > 0) {
128
- details.push(formatSize(meta.artifactSize));
129
- }
130
- const suffix = details.length > 0 ? ` (${details.join(', ')})` : '';
131
- spinner.stopAndPersist({ symbol: chalk.green('✓'), text: chalk.green(`${step.displayName}${suffix}`) });
132
- this.stepSpinners.delete(step.id);
133
- }
134
- this.stderrBuffers.delete(step.id);
135
- }
136
- handleStepWouldRun(step) {
137
- const spinner = this.stepSpinners.get(step.id);
138
- if (spinner) {
139
- spinner.stopAndPersist({ symbol: chalk.yellow('○'), text: chalk.yellow(`${step.displayName} (would run)`) });
140
- this.stepSpinners.delete(step.id);
141
- }
142
- else {
143
- console.log(` ${chalk.yellow('○')} ${chalk.yellow(`${step.displayName} (would run)`)}`);
144
- }
145
- }
146
- handlePipelineFinished(meta) {
147
- const parts = ['Pipeline completed'];
148
- if (typeof meta?.totalArtifactSize === 'number' && meta.totalArtifactSize > 0) {
149
- parts.push(`(${formatSize(meta.totalArtifactSize)})`);
150
- }
151
- console.log(chalk.bold.green(`\n✓ ${parts.join(' ')}\n`));
152
- }
153
- handleStepFailed(step, meta) {
154
- const spinner = this.stepSpinners.get(step.id);
155
- const exitCode = meta?.exitCode;
156
- if (spinner) {
157
- const exitInfo = exitCode === undefined ? '' : ` (exit ${exitCode})`;
158
- spinner.stopAndPersist({
159
- symbol: chalk.red('✗'),
160
- text: chalk.red(`${step.displayName}${exitInfo}`)
161
- });
162
- this.stepSpinners.delete(step.id);
163
- }
164
- const stderr = this.stderrBuffers.get(step.id);
165
- if (stderr && stderr.length > 0) {
166
- console.log(chalk.red(' ── stderr ──'));
167
- for (const line of stderr) {
168
- console.log(chalk.red(` ${line}`));
169
- }
170
- }
171
- this.stderrBuffers.delete(step.id);
172
- }
173
- }
package/dist/cli/state.js CHANGED
@@ -99,4 +99,12 @@ export class StateManager {
99
99
  setStep(stepId, runId, fingerprint) {
100
100
  this.state.steps[stepId] = { runId, fingerprint };
101
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];
109
+ }
102
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
+ }
@@ -0,0 +1,111 @@
1
+ import { ValidationError } from '../errors.js';
2
+ import { getKit } from '../kits/index.js';
3
+ import { isKitStep } from '../types.js';
4
+ import { slugify, mergeEnv, mergeCaches, mergeMounts } from './pipeline-loader.js';
5
+ /**
6
+ * Resolves a step definition into a fully resolved Step.
7
+ * Kit steps (`uses`) are expanded into image + cmd.
8
+ */
9
+ export function resolveStep(step) {
10
+ if (!step.id && !step.name) {
11
+ throw new ValidationError('Invalid step: at least one of "id" or "name" must be defined');
12
+ }
13
+ const id = step.id ?? slugify(step.name);
14
+ const { name } = step;
15
+ if (!isKitStep(step)) {
16
+ return { ...step, id, name };
17
+ }
18
+ return resolveKitStep(step, id, name);
19
+ }
20
+ function resolveKitStep(step, id, name) {
21
+ const kit = getKit(step.uses);
22
+ const kitOutput = kit.resolve(step.with ?? {});
23
+ return {
24
+ id,
25
+ name,
26
+ image: kitOutput.image,
27
+ cmd: kitOutput.cmd,
28
+ env: mergeEnv(kitOutput.env, step.env),
29
+ inputs: step.inputs,
30
+ outputPath: step.outputPath,
31
+ caches: mergeCaches(kitOutput.caches, step.caches),
32
+ mounts: mergeMounts(kitOutput.mounts, step.mounts),
33
+ sources: mergeMounts(kitOutput.sources, step.sources),
34
+ timeoutSec: step.timeoutSec,
35
+ allowFailure: step.allowFailure,
36
+ allowNetwork: step.allowNetwork ?? kitOutput.allowNetwork,
37
+ retries: step.retries,
38
+ retryDelayMs: step.retryDelayMs,
39
+ if: step.if
40
+ };
41
+ }
42
+ /**
43
+ * Validates a resolved step for correctness and security.
44
+ */
45
+ export function validateStep(step) {
46
+ validateIdentifier(step.id, 'step id');
47
+ if (!step.image || typeof step.image !== 'string') {
48
+ throw new ValidationError(`Invalid step ${step.id}: image is required`);
49
+ }
50
+ if (!Array.isArray(step.cmd) || step.cmd.length === 0) {
51
+ throw new ValidationError(`Invalid step ${step.id}: cmd must be a non-empty array`);
52
+ }
53
+ if (step.inputs) {
54
+ for (const input of step.inputs) {
55
+ validateIdentifier(input.step, `input step name in step ${step.id}`);
56
+ }
57
+ }
58
+ if (step.mounts) {
59
+ validateMounts(step.id, step.mounts);
60
+ }
61
+ if (step.sources) {
62
+ validateMounts(step.id, step.sources);
63
+ }
64
+ if (step.caches) {
65
+ validateCaches(step.id, step.caches);
66
+ }
67
+ }
68
+ function validateIdentifier(id, context) {
69
+ if (!/^[\w-]+$/.test(id)) {
70
+ throw new ValidationError(`Invalid ${context}: '${id}' must contain only alphanumeric characters, underscore, and hyphen`);
71
+ }
72
+ if (id.includes('..')) {
73
+ throw new ValidationError(`Invalid ${context}: '${id}' cannot contain '..'`);
74
+ }
75
+ }
76
+ function validateMounts(stepId, mounts) {
77
+ for (const mount of mounts) {
78
+ if (!mount.host || typeof mount.host !== 'string') {
79
+ throw new ValidationError(`Step ${stepId}: mount.host is required and must be a string`);
80
+ }
81
+ if (mount.host.startsWith('/')) {
82
+ throw new ValidationError(`Step ${stepId}: mount.host '${mount.host}' must be a relative path`);
83
+ }
84
+ if (mount.host.includes('..')) {
85
+ throw new ValidationError(`Step ${stepId}: mount.host '${mount.host}' must not contain '..'`);
86
+ }
87
+ if (!mount.container || typeof mount.container !== 'string') {
88
+ throw new ValidationError(`Step ${stepId}: mount.container is required and must be a string`);
89
+ }
90
+ if (!mount.container.startsWith('/')) {
91
+ throw new ValidationError(`Step ${stepId}: mount.container '${mount.container}' must be an absolute path`);
92
+ }
93
+ if (mount.container.includes('..')) {
94
+ throw new ValidationError(`Step ${stepId}: mount.container '${mount.container}' must not contain '..'`);
95
+ }
96
+ }
97
+ }
98
+ function validateCaches(stepId, caches) {
99
+ for (const cache of caches) {
100
+ if (!cache.name || typeof cache.name !== 'string') {
101
+ throw new ValidationError(`Step ${stepId}: cache.name is required and must be a string`);
102
+ }
103
+ validateIdentifier(cache.name, `cache name in step ${stepId}`);
104
+ if (!cache.path || typeof cache.path !== 'string') {
105
+ throw new ValidationError(`Step ${stepId}: cache.path is required and must be a string`);
106
+ }
107
+ if (!cache.path.startsWith('/')) {
108
+ throw new ValidationError(`Step ${stepId}: cache.path '${cache.path}' must be an absolute path`);
109
+ }
110
+ }
111
+ }