@livingdata/pipex 0.0.7 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,9 @@
1
- import { cp } from 'node:fs/promises';
2
- import { dirname, resolve } from 'node:path';
1
+ import { cp, writeFile } from 'node:fs/promises';
2
+ import { createWriteStream } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
3
4
  import { Workspace } from '../engine/index.js';
4
5
  import { StateManager } from './state.js';
6
+ import { dirSize } from './utils.js';
5
7
  /**
6
8
  * Orchestrates pipeline execution with dependency resolution and caching.
7
9
  *
@@ -10,27 +12,20 @@ import { StateManager } from './state.js';
10
12
  * 1. **Workspace Resolution**: Determines workspace ID from CLI flag, config, or filename
11
13
  * 2. **State Loading**: Loads cached fingerprints from state.json
12
14
  * 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
+ * a. Computes fingerprint (image + cmd + env + input run IDs)
16
+ * b. Checks cache (fingerprint match + run exists)
15
17
  * c. If cached: skips execution
16
18
  * 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
+ * e. On success: writes meta.json, commits run, saves state
20
+ * f. On failure: discards run, halts pipeline (unless allowFailure)
19
21
  * 4. **Completion**: Reports final pipeline status
20
22
  *
21
- * ## Dependencies
23
+ * ## Runs
22
24
  *
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
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
34
29
  */
35
30
  export class PipelineRunner {
36
31
  loader;
@@ -44,10 +39,9 @@ export class PipelineRunner {
44
39
  this.workdirRoot = workdirRoot;
45
40
  }
46
41
  async run(pipelineFilePath, options) {
47
- const { workspace: workspaceName, force } = options ?? {};
42
+ const { workspace: workspaceName, force, dryRun } = options ?? {};
48
43
  const config = await this.loader.load(pipelineFilePath);
49
44
  const pipelineRoot = dirname(resolve(pipelineFilePath));
50
- // Workspace ID priority: CLI arg > pipeline id
51
45
  const workspaceId = workspaceName ?? config.id;
52
46
  let workspace;
53
47
  try {
@@ -57,15 +51,19 @@ export class PipelineRunner {
57
51
  workspace = await Workspace.create(this.workdirRoot, workspaceId);
58
52
  }
59
53
  await workspace.cleanupStaging();
60
- await this.runtime.check();
54
+ if (!dryRun) {
55
+ await this.runtime.check();
56
+ await this.runtime.cleanupContainers(workspace.id);
57
+ }
61
58
  const state = new StateManager(workspace.root);
62
59
  await state.load();
63
- const stepArtifacts = new Map();
60
+ const stepRuns = new Map();
61
+ let totalArtifactSize = 0;
64
62
  this.reporter.state(workspace.id, 'PIPELINE_START', undefined, { pipelineName: config.name ?? config.id });
65
63
  for (const step of config.steps) {
66
64
  const stepRef = { id: step.id, displayName: step.name ?? step.id };
67
- const inputArtifactIds = step.inputs
68
- ?.map(i => stepArtifacts.get(i.step))
65
+ const inputRunIds = step.inputs
66
+ ?.map(i => stepRuns.get(i.step))
69
67
  .filter((id) => id !== undefined);
70
68
  const resolvedMounts = step.mounts?.map(m => ({
71
69
  hostPath: resolve(pipelineRoot, m.host),
@@ -75,24 +73,36 @@ export class PipelineRunner {
75
73
  image: step.image,
76
74
  cmd: step.cmd,
77
75
  env: step.env,
78
- inputArtifactIds,
76
+ inputRunIds,
79
77
  mounts: resolvedMounts
80
78
  });
81
79
  const skipCache = force === true || (Array.isArray(force) && force.includes(step.id));
82
- if (!skipCache && await this.tryUseCache({ workspace, state, step, stepRef, currentFingerprint, stepArtifacts })) {
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);
83
85
  continue;
84
86
  }
85
87
  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);
93
- }
88
+ const stepArtifactSize = await this.executeStep({ workspace, state, step, stepRef, stepRuns, currentFingerprint, resolvedMounts, pipelineRoot });
89
+ totalArtifactSize += stepArtifactSize;
90
+ }
91
+ this.reporter.state(workspace.id, 'PIPELINE_FINISHED', undefined, { totalArtifactSize });
92
+ }
93
+ async executeStep({ workspace, state, step, stepRef, stepRuns, currentFingerprint, resolvedMounts, pipelineRoot }) {
94
+ const runId = workspace.generateRunId();
95
+ const stagingPath = await workspace.prepareRun(runId);
96
+ await this.prepareStagingWithInputs(workspace, step, workspace.runStagingArtifactsPath(runId), stepRuns);
97
+ if (step.caches) {
98
+ for (const cache of step.caches) {
99
+ await workspace.prepareCache(cache.name);
94
100
  }
95
- const { inputs, output, caches, mounts } = this.buildMounts(step, artifactId, stepArtifacts, pipelineRoot);
101
+ }
102
+ const { inputs, output, caches, mounts } = this.buildMounts(step, runId, stepRuns, pipelineRoot);
103
+ const stdoutLog = createWriteStream(join(stagingPath, 'stdout.log'));
104
+ const stderrLog = createWriteStream(join(stagingPath, 'stderr.log'));
105
+ try {
96
106
  const result = await this.runtime.run(workspace, {
97
107
  name: `pipex-${workspace.id}-${step.id}-${Date.now()}`,
98
108
  image: step.image,
@@ -109,74 +119,114 @@ export class PipelineRunner {
109
119
  network: step.allowNetwork ? 'bridge' : 'none',
110
120
  timeoutSec: step.timeoutSec
111
121
  }, ({ stream, line }) => {
122
+ if (stream === 'stdout') {
123
+ stdoutLog.write(line + '\n');
124
+ }
125
+ else {
126
+ stderrLog.write(line + '\n');
127
+ }
112
128
  this.reporter.log(workspace.id, stepRef, stream, line);
113
129
  });
114
130
  this.reporter.result(workspace.id, stepRef, result);
131
+ await closeStream(stdoutLog);
132
+ await closeStream(stderrLog);
133
+ await this.writeRunMeta(stagingPath, { runId, step, stepRuns, resolvedMounts, currentFingerprint, result });
115
134
  if (result.exitCode === 0 || step.allowFailure) {
116
- await workspace.commitArtifact(artifactId);
117
- stepArtifacts.set(step.id, artifactId);
118
- state.setStep(step.id, artifactId, currentFingerprint);
135
+ await workspace.commitRun(runId);
136
+ await workspace.linkRun(step.id, runId);
137
+ stepRuns.set(step.id, runId);
138
+ state.setStep(step.id, runId, currentFingerprint);
119
139
  await state.save();
120
- this.reporter.state(workspace.id, 'STEP_FINISHED', stepRef, { artifactId });
121
- }
122
- else {
123
- await workspace.discardArtifact(artifactId);
124
- this.reporter.state(workspace.id, 'STEP_FAILED', stepRef, { exitCode: result.exitCode });
125
- this.reporter.state(workspace.id, 'PIPELINE_FAILED');
126
- throw new Error(`Step ${step.id} failed with exit code ${result.exitCode}`);
140
+ const durationMs = result.finishedAt.getTime() - result.startedAt.getTime();
141
+ const artifactSize = await dirSize(workspace.runArtifactsPath(runId));
142
+ this.reporter.state(workspace.id, 'STEP_FINISHED', stepRef, { runId, durationMs, artifactSize });
143
+ return artifactSize;
127
144
  }
145
+ 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}`);
149
+ }
150
+ catch (error) {
151
+ await closeStream(stdoutLog);
152
+ await closeStream(stderrLog);
153
+ throw error;
128
154
  }
129
- this.reporter.state(workspace.id, 'PIPELINE_FINISHED');
130
155
  }
131
- async tryUseCache({ workspace, state, step, stepRef, currentFingerprint, stepArtifacts }) {
156
+ async writeRunMeta(stagingPath, { runId, step, stepRuns, resolvedMounts, currentFingerprint, result }) {
157
+ const meta = {
158
+ runId,
159
+ stepId: step.id,
160
+ stepName: step.name,
161
+ startedAt: result.startedAt.toISOString(),
162
+ finishedAt: result.finishedAt.toISOString(),
163
+ durationMs: result.finishedAt.getTime() - result.startedAt.getTime(),
164
+ exitCode: result.exitCode,
165
+ image: step.image,
166
+ cmd: step.cmd,
167
+ env: step.env,
168
+ inputs: step.inputs?.map(i => ({
169
+ step: i.step,
170
+ runId: stepRuns.get(i.step),
171
+ mountedAs: `/input/${i.step}`
172
+ })),
173
+ mounts: resolvedMounts,
174
+ caches: step.caches?.map(c => c.name),
175
+ allowNetwork: step.allowNetwork ?? false,
176
+ fingerprint: currentFingerprint,
177
+ status: result.exitCode === 0 ? 'success' : 'failure'
178
+ };
179
+ await writeFile(join(stagingPath, 'meta.json'), JSON.stringify(meta, null, 2), 'utf8');
180
+ }
181
+ async tryUseCache({ workspace, state, step, stepRef, currentFingerprint, stepRuns }) {
132
182
  const cached = state.getStep(step.id);
133
183
  if (cached?.fingerprint === currentFingerprint) {
134
184
  try {
135
- const artifacts = await workspace.listArtifacts();
136
- if (artifacts.includes(cached.artifactId)) {
137
- stepArtifacts.set(step.id, cached.artifactId);
138
- this.reporter.state(workspace.id, 'STEP_SKIPPED', stepRef, { artifactId: cached.artifactId, reason: 'cached' });
185
+ const runs = await workspace.listRuns();
186
+ if (runs.includes(cached.runId)) {
187
+ stepRuns.set(step.id, cached.runId);
188
+ await workspace.linkRun(step.id, cached.runId);
189
+ this.reporter.state(workspace.id, 'STEP_SKIPPED', stepRef, { runId: cached.runId, reason: 'cached' });
139
190
  return true;
140
191
  }
141
192
  }
142
193
  catch {
143
- // Artifact missing, proceed with execution
194
+ // Run missing, proceed with execution
144
195
  }
145
196
  }
146
197
  return false;
147
198
  }
148
- async prepareStagingWithInputs(workspace, step, stagingPath, stepArtifacts) {
199
+ async prepareStagingWithInputs(workspace, step, stagingArtifactsPath, stepRuns) {
149
200
  if (!step.inputs) {
150
201
  return;
151
202
  }
152
203
  for (const input of step.inputs) {
153
- const inputArtifactId = stepArtifacts.get(input.step);
154
- if (!inputArtifactId) {
204
+ const inputRunId = stepRuns.get(input.step);
205
+ if (!inputRunId) {
155
206
  throw new Error(`Step ${step.id}: input step '${input.step}' not found or not yet executed`);
156
207
  }
157
208
  if (input.copyToOutput) {
158
- await cp(workspace.artifactPath(inputArtifactId), stagingPath, { recursive: true });
209
+ await cp(workspace.runArtifactsPath(inputRunId), stagingArtifactsPath, { recursive: true });
159
210
  }
160
211
  }
161
212
  }
162
- buildMounts(step, outputArtifactId, stepArtifacts, pipelineRoot) {
213
+ buildMounts(step, outputRunId, stepRuns, pipelineRoot) {
163
214
  const inputs = [];
164
215
  if (step.inputs) {
165
216
  for (const input of step.inputs) {
166
- const inputArtifactId = stepArtifacts.get(input.step);
167
- if (inputArtifactId) {
217
+ const inputRunId = stepRuns.get(input.step);
218
+ if (inputRunId) {
168
219
  inputs.push({
169
- artifactId: inputArtifactId,
220
+ runId: inputRunId,
170
221
  containerPath: `/input/${input.step}`
171
222
  });
172
223
  }
173
224
  }
174
225
  }
175
226
  const output = {
176
- stagingArtifactId: outputArtifactId,
227
+ stagingRunId: outputRunId,
177
228
  containerPath: step.outputPath ?? '/output'
178
229
  };
179
- // Build cache mounts
180
230
  let caches;
181
231
  if (step.caches) {
182
232
  caches = step.caches.map(c => ({
@@ -194,3 +244,14 @@ export class PipelineRunner {
194
244
  return { inputs, output, caches, mounts };
195
245
  }
196
246
  }
247
+ async function closeStream(stream) {
248
+ if (stream.destroyed) {
249
+ return;
250
+ }
251
+ return new Promise((resolve, reject) => {
252
+ stream.end(() => {
253
+ resolve();
254
+ });
255
+ stream.on('error', reject);
256
+ });
257
+ }
@@ -1,6 +1,7 @@
1
1
  import pino from 'pino';
2
2
  import chalk from 'chalk';
3
3
  import ora from 'ora';
4
+ import { formatDuration, formatSize } from './utils.js';
4
5
  /**
5
6
  * Reporter that outputs structured JSON logs via pino.
6
7
  * Suitable for CI/CD environments and log aggregation.
@@ -26,9 +27,13 @@ export class InteractiveReporter {
26
27
  static get maxStderrLines() {
27
28
  return 20;
28
29
  }
30
+ verbose;
29
31
  spinner;
30
32
  stepSpinners = new Map();
31
33
  stderrBuffers = new Map();
34
+ constructor(options) {
35
+ this.verbose = options?.verbose ?? false;
36
+ }
32
37
  state(workspaceId, event, step, meta) {
33
38
  switch (event) {
34
39
  case 'PIPELINE_START': {
@@ -58,12 +63,7 @@ export class InteractiveReporter {
58
63
  }
59
64
  case 'STEP_FINISHED': {
60
65
  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);
66
+ this.handleStepFinished(step, meta);
67
67
  }
68
68
  break;
69
69
  }
@@ -73,8 +73,14 @@ export class InteractiveReporter {
73
73
  }
74
74
  break;
75
75
  }
76
+ case 'STEP_WOULD_RUN': {
77
+ if (step) {
78
+ this.handleStepWouldRun(step);
79
+ }
80
+ break;
81
+ }
76
82
  case 'PIPELINE_FINISHED': {
77
- console.log(chalk.bold.green('\n✓ Pipeline completed\n'));
83
+ this.handlePipelineFinished(meta);
78
84
  break;
79
85
  }
80
86
  case 'PIPELINE_FAILED': {
@@ -84,6 +90,18 @@ export class InteractiveReporter {
84
90
  }
85
91
  }
86
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
+ }
87
105
  if (stream === 'stderr') {
88
106
  let buffer = this.stderrBuffers.get(step.id);
89
107
  if (!buffer) {
@@ -99,6 +117,39 @@ export class InteractiveReporter {
99
117
  result(_workspaceId, _step, _result) {
100
118
  // Results shown via state updates
101
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
+ }
102
153
  handleStepFailed(step, meta) {
103
154
  const spinner = this.stepSpinners.get(step.id);
104
155
  const exitCode = meta?.exitCode;
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,26 @@ 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 };
88
101
  }
89
102
  }
@@ -0,0 +1,49 @@
1
+ import { readdir, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ export function getGlobalOptions(cmd) {
4
+ return cmd.optsWithGlobals();
5
+ }
6
+ export async function dirSize(dirPath) {
7
+ let total = 0;
8
+ try {
9
+ const entries = await readdir(dirPath, { withFileTypes: true });
10
+ for (const entry of entries) {
11
+ const fullPath = join(dirPath, entry.name);
12
+ if (entry.isDirectory()) {
13
+ total += await dirSize(fullPath);
14
+ }
15
+ else if (entry.isFile()) {
16
+ const s = await stat(fullPath);
17
+ total += s.size;
18
+ }
19
+ }
20
+ }
21
+ catch {
22
+ // Directory doesn't exist or isn't readable
23
+ }
24
+ return total;
25
+ }
26
+ export function formatSize(bytes) {
27
+ if (bytes < 1024) {
28
+ return `${bytes} B`;
29
+ }
30
+ if (bytes < 1024 * 1024) {
31
+ return `${(bytes / 1024).toFixed(1)} KB`;
32
+ }
33
+ if (bytes < 1024 * 1024 * 1024) {
34
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
35
+ }
36
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
37
+ }
38
+ export function formatDuration(ms) {
39
+ if (ms < 1000) {
40
+ return `${ms}ms`;
41
+ }
42
+ const seconds = ms / 1000;
43
+ if (seconds < 60) {
44
+ return `${seconds.toFixed(1)}s`;
45
+ }
46
+ const minutes = Math.floor(seconds / 60);
47
+ const remainingSeconds = Math.round(seconds % 60);
48
+ return `${minutes}m ${remainingSeconds}s`;
49
+ }
@@ -26,20 +26,38 @@ export class DockerCliExecutor extends ContainerExecutor {
26
26
  throw new Error('Docker CLI not found. Please install Docker.');
27
27
  }
28
28
  }
29
+ /**
30
+ * Remove any leftover pipex containers for the given workspace.
31
+ * Called before pipeline execution to clean up after crashes.
32
+ */
33
+ async cleanupContainers(workspaceId) {
34
+ try {
35
+ const { stdout } = await execa('docker', [
36
+ 'ps', '-a', '--filter', `label=pipex.workspace=${workspaceId}`, '-q'
37
+ ], { env: this.env });
38
+ const ids = stdout.trim().split('\n').filter(Boolean);
39
+ if (ids.length > 0) {
40
+ await execa('docker', ['rm', '-f', ...ids], { env: this.env, reject: false });
41
+ }
42
+ }
43
+ catch {
44
+ // Best effort
45
+ }
46
+ }
29
47
  async run(workspace, request, onLogLine) {
30
48
  const startedAt = new Date();
31
49
  // Use create+start instead of run: docker run cannot create mountpoints
32
50
  // for anonymous volumes inside read-only bind mounts (shadow paths).
33
51
  // docker create sets up the filesystem layer before readonly applies.
34
- const args = ['create', '--name', request.name, '--network', request.network];
52
+ const args = ['create', '--name', request.name, '--network', request.network, '--label', 'pipex=true', '--label', `pipex.workspace=${workspace.id}`];
35
53
  if (request.env) {
36
54
  for (const [key, value] of Object.entries(request.env)) {
37
55
  args.push('-e', `${key}=${value}`);
38
56
  }
39
57
  }
40
- // Mount inputs (committed artifacts, read-only)
58
+ // Mount inputs (committed run artifacts, read-only)
41
59
  for (const input of request.inputs) {
42
- const hostPath = workspace.artifactPath(input.artifactId);
60
+ const hostPath = workspace.runArtifactsPath(input.runId);
43
61
  args.push('-v', `${hostPath}:${input.containerPath}:ro`);
44
62
  }
45
63
  // Mount caches (persistent, read-write)
@@ -55,8 +73,8 @@ export class DockerCliExecutor extends ContainerExecutor {
55
73
  args.push('-v', `${mount.hostPath}:${mount.containerPath}:ro`);
56
74
  }
57
75
  }
58
- // Mount output (staging artifact, read-write)
59
- const outputHostPath = workspace.stagingPath(request.output.stagingArtifactId);
76
+ // Mount output (staging run artifacts, read-write)
77
+ const outputHostPath = workspace.runStagingArtifactsPath(request.output.stagingRunId);
60
78
  args.push('-v', `${outputHostPath}:${request.output.containerPath}:rw`, request.image, ...request.cmd);
61
79
  let exitCode = 0;
62
80
  let error;