@livingdata/pipex 0.0.8 → 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,76 +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
- await workspace.linkArtifact(step.id, artifactId);
118
- stepArtifacts.set(step.id, artifactId);
119
- 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);
120
139
  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}`);
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;
128
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;
129
154
  }
130
- this.reporter.state(workspace.id, 'PIPELINE_FINISHED');
131
155
  }
132
- 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 }) {
133
182
  const cached = state.getStep(step.id);
134
183
  if (cached?.fingerprint === currentFingerprint) {
135
184
  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' });
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' });
141
190
  return true;
142
191
  }
143
192
  }
144
193
  catch {
145
- // Artifact missing, proceed with execution
194
+ // Run missing, proceed with execution
146
195
  }
147
196
  }
148
197
  return false;
149
198
  }
150
- async prepareStagingWithInputs(workspace, step, stagingPath, stepArtifacts) {
199
+ async prepareStagingWithInputs(workspace, step, stagingArtifactsPath, stepRuns) {
151
200
  if (!step.inputs) {
152
201
  return;
153
202
  }
154
203
  for (const input of step.inputs) {
155
- const inputArtifactId = stepArtifacts.get(input.step);
156
- if (!inputArtifactId) {
204
+ const inputRunId = stepRuns.get(input.step);
205
+ if (!inputRunId) {
157
206
  throw new Error(`Step ${step.id}: input step '${input.step}' not found or not yet executed`);
158
207
  }
159
208
  if (input.copyToOutput) {
160
- await cp(workspace.artifactPath(inputArtifactId), stagingPath, { recursive: true });
209
+ await cp(workspace.runArtifactsPath(inputRunId), stagingArtifactsPath, { recursive: true });
161
210
  }
162
211
  }
163
212
  }
164
- buildMounts(step, outputArtifactId, stepArtifacts, pipelineRoot) {
213
+ buildMounts(step, outputRunId, stepRuns, pipelineRoot) {
165
214
  const inputs = [];
166
215
  if (step.inputs) {
167
216
  for (const input of step.inputs) {
168
- const inputArtifactId = stepArtifacts.get(input.step);
169
- if (inputArtifactId) {
217
+ const inputRunId = stepRuns.get(input.step);
218
+ if (inputRunId) {
170
219
  inputs.push({
171
- artifactId: inputArtifactId,
220
+ runId: inputRunId,
172
221
  containerPath: `/input/${input.step}`
173
222
  });
174
223
  }
175
224
  }
176
225
  }
177
226
  const output = {
178
- stagingArtifactId: outputArtifactId,
227
+ stagingRunId: outputRunId,
179
228
  containerPath: step.outputPath ?? '/output'
180
229
  };
181
- // Build cache mounts
182
230
  let caches;
183
231
  if (step.caches) {
184
232
  caches = step.caches.map(c => ({
@@ -196,3 +244,14 @@ export class PipelineRunner {
196
244
  return { inputs, output, caches, mounts };
197
245
  }
198
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;