@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
@@ -0,0 +1,288 @@
1
+ import { access, readdir, readlink, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import test from 'ava';
4
+ import { Workspace } from '../workspace.js';
5
+ import { WorkspaceError } from '../../errors.js';
6
+ import { createTmpDir } from '../../__tests__/helpers.js';
7
+ // -- create & open -----------------------------------------------------------
8
+ test('create makes staging/, runs/, caches/ directories', async (t) => {
9
+ const root = await createTmpDir();
10
+ const ws = await Workspace.create(root, 'ws-1');
11
+ const entries = await readdir(ws.root);
12
+ t.true(entries.includes('staging'));
13
+ t.true(entries.includes('runs'));
14
+ t.true(entries.includes('caches'));
15
+ });
16
+ test('create with custom ID uses that ID', async (t) => {
17
+ const root = await createTmpDir();
18
+ const ws = await Workspace.create(root, 'my-workspace');
19
+ t.is(ws.id, 'my-workspace');
20
+ });
21
+ test('create without ID auto-generates one', async (t) => {
22
+ const root = await createTmpDir();
23
+ const ws = await Workspace.create(root);
24
+ t.truthy(ws.id);
25
+ t.true(ws.id.length > 0);
26
+ });
27
+ test('open returns workspace with correct id and root', async (t) => {
28
+ const root = await createTmpDir();
29
+ await Workspace.create(root, 'existing');
30
+ const ws = await Workspace.open(root, 'existing');
31
+ t.is(ws.id, 'existing');
32
+ t.is(ws.root, join(root, 'existing'));
33
+ });
34
+ test('open throws if workspace missing', async (t) => {
35
+ const root = await createTmpDir();
36
+ await t.throwsAsync(async () => Workspace.open(root, 'nonexistent'));
37
+ });
38
+ // -- list & remove -----------------------------------------------------------
39
+ test('list returns sorted workspace IDs', async (t) => {
40
+ const root = await createTmpDir();
41
+ await Workspace.create(root, 'beta');
42
+ await Workspace.create(root, 'alpha');
43
+ await Workspace.create(root, 'gamma');
44
+ const ids = await Workspace.list(root);
45
+ t.deepEqual(ids, ['alpha', 'beta', 'gamma']);
46
+ });
47
+ test('list returns empty array if root does not exist', async (t) => {
48
+ const ids = await Workspace.list('/tmp/nonexistent-pipex-root-' + Date.now());
49
+ t.deepEqual(ids, []);
50
+ });
51
+ test('remove deletes workspace', async (t) => {
52
+ const root = await createTmpDir();
53
+ await Workspace.create(root, 'to-delete');
54
+ await Workspace.remove(root, 'to-delete');
55
+ const ids = await Workspace.list(root);
56
+ t.false(ids.includes('to-delete'));
57
+ });
58
+ test('remove throws WorkspaceError on path traversal with ..', async (t) => {
59
+ const root = await createTmpDir();
60
+ const error = await t.throwsAsync(async () => Workspace.remove(root, '../etc'));
61
+ t.true(error instanceof WorkspaceError);
62
+ });
63
+ test('remove throws WorkspaceError on path traversal with /', async (t) => {
64
+ const error = await t.throwsAsync(async () => Workspace.remove('/tmp', 'a/b'));
65
+ t.true(error instanceof WorkspaceError);
66
+ });
67
+ // -- run lifecycle: commit path ----------------------------------------------
68
+ test('prepareRun creates staging/{runId}/artifacts/', async (t) => {
69
+ const root = await createTmpDir();
70
+ const ws = await Workspace.create(root, 'run-test');
71
+ const runId = ws.generateRunId();
72
+ await ws.prepareRun(runId);
73
+ const stagingPath = ws.runStagingPath(runId);
74
+ await t.notThrowsAsync(async () => access(stagingPath));
75
+ await t.notThrowsAsync(async () => access(join(stagingPath, 'artifacts')));
76
+ });
77
+ test('commitRun moves staging to runs', async (t) => {
78
+ const root = await createTmpDir();
79
+ const ws = await Workspace.create(root, 'commit-test');
80
+ const runId = ws.generateRunId();
81
+ await ws.prepareRun(runId);
82
+ // Write a file into staging artifacts
83
+ await writeFile(join(ws.runStagingArtifactsPath(runId), 'file.txt'), 'hello');
84
+ await ws.commitRun(runId);
85
+ // Committed run should exist
86
+ await t.notThrowsAsync(async () => access(ws.runPath(runId)));
87
+ await t.notThrowsAsync(async () => access(join(ws.runArtifactsPath(runId), 'file.txt')));
88
+ });
89
+ test('committed run appears in listRuns', async (t) => {
90
+ const root = await createTmpDir();
91
+ const ws = await Workspace.create(root, 'list-runs');
92
+ const runId = ws.generateRunId();
93
+ await ws.prepareRun(runId);
94
+ await ws.commitRun(runId);
95
+ const runs = await ws.listRuns();
96
+ t.true(runs.includes(runId));
97
+ });
98
+ test('staging is empty after commit', async (t) => {
99
+ const root = await createTmpDir();
100
+ const ws = await Workspace.create(root, 'staging-empty');
101
+ const runId = ws.generateRunId();
102
+ await ws.prepareRun(runId);
103
+ await ws.commitRun(runId);
104
+ const staging = await readdir(join(ws.root, 'staging'));
105
+ t.deepEqual(staging, []);
106
+ });
107
+ // -- run lifecycle: discard path ---------------------------------------------
108
+ test('discardRun removes staging directory', async (t) => {
109
+ const root = await createTmpDir();
110
+ const ws = await Workspace.create(root, 'discard-test');
111
+ const runId = ws.generateRunId();
112
+ await ws.prepareRun(runId);
113
+ await ws.discardRun(runId);
114
+ const staging = await readdir(join(ws.root, 'staging'));
115
+ t.deepEqual(staging, []);
116
+ });
117
+ test('discarded run does not appear in listRuns', async (t) => {
118
+ const root = await createTmpDir();
119
+ const ws = await Workspace.create(root, 'discard-list');
120
+ const runId = ws.generateRunId();
121
+ await ws.prepareRun(runId);
122
+ await ws.discardRun(runId);
123
+ const runs = await ws.listRuns();
124
+ t.false(runs.includes(runId));
125
+ });
126
+ // -- cleanupStaging ----------------------------------------------------------
127
+ test('cleanupStaging removes all staging dirs', async (t) => {
128
+ const root = await createTmpDir();
129
+ const ws = await Workspace.create(root, 'cleanup-test');
130
+ await ws.prepareRun(ws.generateRunId());
131
+ await ws.prepareRun(ws.generateRunId());
132
+ await ws.cleanupStaging();
133
+ const staging = await readdir(join(ws.root, 'staging'));
134
+ t.deepEqual(staging, []);
135
+ });
136
+ test('cleanupStaging is no-op when empty', async (t) => {
137
+ const root = await createTmpDir();
138
+ const ws = await Workspace.create(root, 'cleanup-noop');
139
+ await t.notThrowsAsync(async () => ws.cleanupStaging());
140
+ });
141
+ // -- linkRun -----------------------------------------------------------------
142
+ test('linkRun creates step-runs/{stepId} symlink', async (t) => {
143
+ const root = await createTmpDir();
144
+ const ws = await Workspace.create(root, 'link-test');
145
+ const runId = ws.generateRunId();
146
+ await ws.prepareRun(runId);
147
+ await ws.commitRun(runId);
148
+ await ws.linkRun('my-step', runId);
149
+ const linkDir = join(ws.root, 'step-runs');
150
+ const entries = await readdir(linkDir);
151
+ t.true(entries.includes('my-step'));
152
+ });
153
+ test('linkRun replaces existing symlink with new target', async (t) => {
154
+ const root = await createTmpDir();
155
+ const ws = await Workspace.create(root, 'link-replace');
156
+ const runId1 = ws.generateRunId();
157
+ await ws.prepareRun(runId1);
158
+ await ws.commitRun(runId1);
159
+ await ws.linkRun('step-a', runId1);
160
+ const runId2 = ws.generateRunId();
161
+ await ws.prepareRun(runId2);
162
+ await ws.commitRun(runId2);
163
+ await ws.linkRun('step-a', runId2);
164
+ // Symlink must point to the second run, not the first
165
+ const target = await readlink(join(ws.root, 'step-runs', 'step-a'));
166
+ t.true(target.includes(runId2));
167
+ t.false(target.includes(runId1));
168
+ });
169
+ // -- pruneRuns ---------------------------------------------------------------
170
+ test('pruneRuns removes runs not in active set', async (t) => {
171
+ const root = await createTmpDir();
172
+ const ws = await Workspace.create(root, 'prune-test');
173
+ const keep = ws.generateRunId();
174
+ await ws.prepareRun(keep);
175
+ await ws.commitRun(keep);
176
+ const remove = ws.generateRunId();
177
+ await ws.prepareRun(remove);
178
+ await ws.commitRun(remove);
179
+ const removed = await ws.pruneRuns(new Set([keep]));
180
+ t.is(removed, 1);
181
+ const runs = await ws.listRuns();
182
+ t.true(runs.includes(keep));
183
+ t.false(runs.includes(remove));
184
+ });
185
+ test('pruneRuns keeps runs in active set', async (t) => {
186
+ const root = await createTmpDir();
187
+ const ws = await Workspace.create(root, 'prune-keep');
188
+ const run1 = ws.generateRunId();
189
+ await ws.prepareRun(run1);
190
+ await ws.commitRun(run1);
191
+ const run2 = ws.generateRunId();
192
+ await ws.prepareRun(run2);
193
+ await ws.commitRun(run2);
194
+ const removed = await ws.pruneRuns(new Set([run1, run2]));
195
+ t.is(removed, 0);
196
+ });
197
+ // -- caches ------------------------------------------------------------------
198
+ test('prepareCache creates cache directory', async (t) => {
199
+ const root = await createTmpDir();
200
+ const ws = await Workspace.create(root, 'cache-test');
201
+ const path = await ws.prepareCache('npm-cache');
202
+ await t.notThrowsAsync(async () => access(path));
203
+ });
204
+ test('listCaches lists cache names', async (t) => {
205
+ const root = await createTmpDir();
206
+ const ws = await Workspace.create(root, 'cache-list');
207
+ await ws.prepareCache('cache-a');
208
+ await ws.prepareCache('cache-b');
209
+ const caches = await ws.listCaches();
210
+ t.true(caches.includes('cache-a'));
211
+ t.true(caches.includes('cache-b'));
212
+ });
213
+ test('invalid cache name throws WorkspaceError', async (t) => {
214
+ const root = await createTmpDir();
215
+ const ws = await Workspace.create(root, 'cache-invalid');
216
+ const error = t.throws(() => ws.cachePath('invalid/name'));
217
+ t.true(error instanceof WorkspaceError);
218
+ t.true(error.message.includes('invalid/name'));
219
+ });
220
+ // -- running step markers ----------------------------------------------------
221
+ test('markStepRunning creates running/{stepId} file', async (t) => {
222
+ const root = await createTmpDir();
223
+ const ws = await Workspace.create(root, 'running-mark');
224
+ await ws.markStepRunning('build', { startedAt: '2024-01-01T00:00:00Z', pid: 12345 });
225
+ const entries = await readdir(join(ws.root, 'running'));
226
+ t.deepEqual(entries, ['build']);
227
+ });
228
+ test('markStepDone removes the running marker', async (t) => {
229
+ const root = await createTmpDir();
230
+ const ws = await Workspace.create(root, 'running-done');
231
+ await ws.markStepRunning('build', { startedAt: '2024-01-01T00:00:00Z', pid: 12345 });
232
+ await ws.markStepDone('build');
233
+ const entries = await readdir(join(ws.root, 'running'));
234
+ t.deepEqual(entries, []);
235
+ });
236
+ test('markStepDone is no-op for non-existent marker', async (t) => {
237
+ const root = await createTmpDir();
238
+ const ws = await Workspace.create(root, 'running-done-noop');
239
+ await t.notThrowsAsync(async () => ws.markStepDone('nonexistent'));
240
+ });
241
+ test('listRunningSteps returns all running steps with metadata', async (t) => {
242
+ const root = await createTmpDir();
243
+ const ws = await Workspace.create(root, 'running-list');
244
+ await ws.markStepRunning('build', { startedAt: '2024-01-01T00:00:00Z', pid: 111, stepName: 'Build' });
245
+ await ws.markStepRunning('test', { startedAt: '2024-01-01T00:01:00Z', pid: 222 });
246
+ const running = await ws.listRunningSteps();
247
+ t.is(running.length, 2);
248
+ const build = running.find(r => r.stepId === 'build');
249
+ t.is(build.pid, 111);
250
+ t.is(build.stepName, 'Build');
251
+ t.is(build.startedAt, '2024-01-01T00:00:00Z');
252
+ const testStep = running.find(r => r.stepId === 'test');
253
+ t.is(testStep.pid, 222);
254
+ t.is(testStep.stepName, undefined);
255
+ });
256
+ test('listRunningSteps returns empty array when no running directory', async (t) => {
257
+ const root = await createTmpDir();
258
+ const ws = await Workspace.create(root, 'running-empty');
259
+ const running = await ws.listRunningSteps();
260
+ t.deepEqual(running, []);
261
+ });
262
+ test('cleanupRunning removes all running markers', async (t) => {
263
+ const root = await createTmpDir();
264
+ const ws = await Workspace.create(root, 'running-cleanup');
265
+ await ws.markStepRunning('a', { startedAt: '2024-01-01T00:00:00Z', pid: 1 });
266
+ await ws.markStepRunning('b', { startedAt: '2024-01-01T00:00:00Z', pid: 2 });
267
+ await ws.cleanupRunning();
268
+ const running = await ws.listRunningSteps();
269
+ t.deepEqual(running, []);
270
+ });
271
+ test('cleanupRunning is no-op when no running directory', async (t) => {
272
+ const root = await createTmpDir();
273
+ const ws = await Workspace.create(root, 'running-cleanup-noop');
274
+ await t.notThrowsAsync(async () => ws.cleanupRunning());
275
+ });
276
+ // -- validation --------------------------------------------------------------
277
+ test('invalid run ID in runStagingPath throws WorkspaceError', async (t) => {
278
+ const root = await createTmpDir();
279
+ const ws = await Workspace.create(root, 'validate-staging');
280
+ const error = t.throws(() => ws.runStagingPath('bad/id'));
281
+ t.true(error instanceof WorkspaceError);
282
+ });
283
+ test('invalid run ID in runPath throws WorkspaceError', async (t) => {
284
+ const root = await createTmpDir();
285
+ const ws = await Workspace.create(root, 'validate-run');
286
+ const error = t.throws(() => ws.runPath('bad id'));
287
+ t.true(error instanceof WorkspaceError);
288
+ });
@@ -1,5 +1,6 @@
1
1
  import process from 'node:process';
2
2
  import { execa } from 'execa';
3
+ import { DockerNotAvailableError, ImagePullError, ContainerTimeoutError } from '../errors.js';
3
4
  import { ContainerExecutor } from './executor.js';
4
5
  /**
5
6
  * Build a minimal environment for the Docker CLI process.
@@ -22,8 +23,8 @@ export class DockerCliExecutor extends ContainerExecutor {
22
23
  try {
23
24
  await execa('docker', ['--version'], { env: this.env });
24
25
  }
25
- catch {
26
- throw new Error('Docker CLI not found. Please install Docker.');
26
+ catch (error) {
27
+ throw new DockerNotAvailableError({ cause: error });
27
28
  }
28
29
  }
29
30
  /**
@@ -108,6 +109,13 @@ export class DockerCliExecutor extends ContainerExecutor {
108
109
  exitCode = result.exitCode ?? 0;
109
110
  }
110
111
  catch (error_) {
112
+ if (error_ instanceof Error && 'timedOut' in error_ && error_.timedOut) {
113
+ throw new ContainerTimeoutError(request.timeoutSec ?? 0, { cause: error_ });
114
+ }
115
+ const stderr = error_ instanceof Error && 'stderr' in error_ ? String(error_.stderr) : '';
116
+ if (/unable to find image|pull access denied|manifest unknown/i.test(stderr)) {
117
+ throw new ImagePullError(request.image, { cause: error_ });
118
+ }
111
119
  exitCode = 1;
112
120
  error = error_ instanceof Error ? error_.message : String(error_);
113
121
  }
@@ -1,3 +1,4 @@
1
1
  export { Workspace } from './workspace.js';
2
2
  export { ContainerExecutor } from './executor.js';
3
3
  export { DockerCliExecutor } from './docker-executor.js';
4
+ export { PipexError, DockerError, DockerNotAvailableError, ImagePullError, ContainerTimeoutError, ContainerCrashError, ContainerCleanupError, WorkspaceError, ArtifactNotFoundError, StagingError, PipelineError, ValidationError, CyclicDependencyError, StepNotFoundError, KitError, MissingParameterError } from '../errors.js';
@@ -1,6 +1,7 @@
1
- import { access, mkdir, readdir, rename, rm, symlink } from 'node:fs/promises';
1
+ import { access, mkdir, readdir, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { join } from 'node:path';
4
+ import { WorkspaceError, StagingError } from '../errors.js';
4
5
  /**
5
6
  * Isolated execution environment for container runs.
6
7
  *
@@ -100,7 +101,7 @@ export class Workspace {
100
101
  */
101
102
  static async remove(workdirRoot, id) {
102
103
  if (id.includes('..') || id.includes('/')) {
103
- throw new Error(`Invalid workspace ID: ${id}`);
104
+ throw new WorkspaceError('INVALID_WORKSPACE_ID', `Invalid workspace ID: ${id}`);
104
105
  }
105
106
  await rm(join(workdirRoot, id), { recursive: true, force: true });
106
107
  }
@@ -164,10 +165,15 @@ export class Workspace {
164
165
  * @returns Absolute path to the created staging directory
165
166
  */
166
167
  async prepareRun(runId) {
167
- const path = this.runStagingPath(runId);
168
- await mkdir(path, { recursive: true });
169
- await mkdir(join(path, 'artifacts'), { recursive: true });
170
- return path;
168
+ try {
169
+ const path = this.runStagingPath(runId);
170
+ await mkdir(path, { recursive: true });
171
+ await mkdir(join(path, 'artifacts'), { recursive: true });
172
+ return path;
173
+ }
174
+ catch (error) {
175
+ throw new StagingError(`Failed to prepare run ${runId}`, { cause: error });
176
+ }
171
177
  }
172
178
  /**
173
179
  * Commits a staging run to the runs directory.
@@ -175,7 +181,12 @@ export class Workspace {
175
181
  * @param runId - Run identifier
176
182
  */
177
183
  async commitRun(runId) {
178
- await rename(this.runStagingPath(runId), this.runPath(runId));
184
+ try {
185
+ await rename(this.runStagingPath(runId), this.runPath(runId));
186
+ }
187
+ catch (error) {
188
+ throw new StagingError(`Failed to commit run ${runId}`, { cause: error });
189
+ }
179
190
  }
180
191
  /**
181
192
  * Creates a symlink from `step-runs/{stepId}` to the committed run.
@@ -195,7 +206,12 @@ export class Workspace {
195
206
  * @param runId - Run identifier
196
207
  */
197
208
  async discardRun(runId) {
198
- await rm(this.runStagingPath(runId), { recursive: true, force: true });
209
+ try {
210
+ await rm(this.runStagingPath(runId), { recursive: true, force: true });
211
+ }
212
+ catch (error) {
213
+ throw new StagingError(`Failed to discard run ${runId}`, { cause: error });
214
+ }
199
215
  }
200
216
  /**
201
217
  * Removes all staging directories.
@@ -279,6 +295,54 @@ export class Workspace {
279
295
  return [];
280
296
  }
281
297
  }
298
+ /**
299
+ * Marks a step as currently running by writing a marker file.
300
+ * @param stepId - Step identifier
301
+ * @param meta - Metadata about the running step
302
+ */
303
+ async markStepRunning(stepId, meta) {
304
+ const dir = join(this.root, 'running');
305
+ await mkdir(dir, { recursive: true });
306
+ await writeFile(join(dir, stepId), JSON.stringify(meta), 'utf8');
307
+ }
308
+ /**
309
+ * Removes the running marker for a step.
310
+ * @param stepId - Step identifier
311
+ */
312
+ async markStepDone(stepId) {
313
+ await rm(join(this.root, 'running', stepId), { force: true });
314
+ }
315
+ /**
316
+ * Lists all steps currently marked as running.
317
+ * @returns Array of running step metadata
318
+ */
319
+ async listRunningSteps() {
320
+ const dir = join(this.root, 'running');
321
+ try {
322
+ const entries = await readdir(dir);
323
+ const results = [];
324
+ for (const entry of entries) {
325
+ try {
326
+ const content = await readFile(join(dir, entry), 'utf8');
327
+ const meta = JSON.parse(content);
328
+ results.push({ stepId: entry, ...meta });
329
+ }
330
+ catch {
331
+ // Ignore malformed entries
332
+ }
333
+ }
334
+ return results;
335
+ }
336
+ catch {
337
+ return [];
338
+ }
339
+ }
340
+ /**
341
+ * Removes all running markers.
342
+ */
343
+ async cleanupRunning() {
344
+ await rm(join(this.root, 'running'), { recursive: true, force: true });
345
+ }
282
346
  /**
283
347
  * Validates a run ID to prevent path traversal attacks.
284
348
  * @param id - Run identifier to validate
@@ -287,10 +351,10 @@ export class Workspace {
287
351
  */
288
352
  validateRunId(id) {
289
353
  if (!/^[\w-]+$/.test(id)) {
290
- throw new Error(`Invalid run ID: ${id}. Must contain only alphanumeric characters, dashes, and underscores.`);
354
+ throw new WorkspaceError('INVALID_RUN_ID', `Invalid run ID: ${id}. Must contain only alphanumeric characters, dashes, and underscores.`);
291
355
  }
292
356
  if (id.includes('..')) {
293
- throw new Error(`Invalid run ID: ${id}. Path traversal is not allowed.`);
357
+ throw new WorkspaceError('INVALID_RUN_ID', `Invalid run ID: ${id}. Path traversal is not allowed.`);
294
358
  }
295
359
  }
296
360
  /**
@@ -302,10 +366,10 @@ export class Workspace {
302
366
  */
303
367
  validateCacheName(name) {
304
368
  if (!/^[\w-]+$/.test(name)) {
305
- throw new Error(`Invalid cache name: ${name}. Must contain only alphanumeric characters, dashes, and underscores.`);
369
+ throw new WorkspaceError('INVALID_CACHE_NAME', `Invalid cache name: ${name}. Must contain only alphanumeric characters, dashes, and underscores.`);
306
370
  }
307
371
  if (name.includes('..')) {
308
- throw new Error(`Invalid cache name: ${name}. Path traversal is not allowed.`);
372
+ throw new WorkspaceError('INVALID_CACHE_NAME', `Invalid cache name: ${name}. Path traversal is not allowed.`);
309
373
  }
310
374
  }
311
375
  }
package/dist/errors.js ADDED
@@ -0,0 +1,122 @@
1
+ export class PipexError extends Error {
2
+ code;
3
+ constructor(code, message, options) {
4
+ super(message, options);
5
+ this.code = code;
6
+ this.name = 'PipexError';
7
+ }
8
+ get transient() {
9
+ return false;
10
+ }
11
+ }
12
+ // -- Docker errors -----------------------------------------------------------
13
+ export class DockerError extends PipexError {
14
+ constructor(code, message, options) {
15
+ super(code, message, options);
16
+ this.name = 'DockerError';
17
+ }
18
+ }
19
+ export class DockerNotAvailableError extends DockerError {
20
+ constructor(options) {
21
+ super('DOCKER_NOT_AVAILABLE', 'Docker CLI not found. Please install Docker.', options);
22
+ this.name = 'DockerNotAvailableError';
23
+ }
24
+ get transient() {
25
+ return true;
26
+ }
27
+ }
28
+ export class ImagePullError extends DockerError {
29
+ constructor(image, options) {
30
+ super('IMAGE_PULL_FAILED', `Failed to pull image "${image}"`, options);
31
+ this.name = 'ImagePullError';
32
+ }
33
+ get transient() {
34
+ return true;
35
+ }
36
+ }
37
+ export class ContainerTimeoutError extends DockerError {
38
+ constructor(timeoutSec, options) {
39
+ super('CONTAINER_TIMEOUT', `Container exceeded timeout of ${timeoutSec}s`, options);
40
+ this.name = 'ContainerTimeoutError';
41
+ }
42
+ }
43
+ export class ContainerCrashError extends DockerError {
44
+ stepId;
45
+ exitCode;
46
+ constructor(stepId, exitCode, options) {
47
+ super('CONTAINER_CRASH', `Step ${stepId} failed with exit code ${exitCode}`, options);
48
+ this.stepId = stepId;
49
+ this.exitCode = exitCode;
50
+ this.name = 'ContainerCrashError';
51
+ }
52
+ }
53
+ export class ContainerCleanupError extends DockerError {
54
+ constructor(options) {
55
+ super('CONTAINER_CLEANUP_FAILED', 'Failed to clean up container', options);
56
+ this.name = 'ContainerCleanupError';
57
+ }
58
+ }
59
+ // -- Workspace errors --------------------------------------------------------
60
+ export class WorkspaceError extends PipexError {
61
+ constructor(code, message, options) {
62
+ super(code, message, options);
63
+ this.name = 'WorkspaceError';
64
+ }
65
+ }
66
+ export class ArtifactNotFoundError extends WorkspaceError {
67
+ constructor(message, options) {
68
+ super('ARTIFACT_NOT_FOUND', message, options);
69
+ this.name = 'ArtifactNotFoundError';
70
+ }
71
+ }
72
+ export class StagingError extends WorkspaceError {
73
+ constructor(message, options) {
74
+ super('STAGING_FAILED', message, options);
75
+ this.name = 'StagingError';
76
+ }
77
+ }
78
+ // -- Pipeline errors ---------------------------------------------------------
79
+ export class PipelineError extends PipexError {
80
+ constructor(code, message, options) {
81
+ super(code, message, options);
82
+ this.name = 'PipelineError';
83
+ }
84
+ }
85
+ export class ValidationError extends PipelineError {
86
+ constructor(message, options) {
87
+ super('VALIDATION_ERROR', message, options);
88
+ this.name = 'ValidationError';
89
+ }
90
+ }
91
+ export class CyclicDependencyError extends PipelineError {
92
+ constructor(message, options) {
93
+ super('CYCLIC_DEPENDENCY', message, options);
94
+ this.name = 'CyclicDependencyError';
95
+ }
96
+ }
97
+ export class StepNotFoundError extends PipelineError {
98
+ constructor(stepId, referencedStep, options) {
99
+ super('STEP_NOT_FOUND', `Step ${stepId}: input step '${referencedStep}' not found or not yet executed`, options);
100
+ this.name = 'StepNotFoundError';
101
+ }
102
+ }
103
+ // -- Bundle errors -----------------------------------------------------------
104
+ export class BundleError extends PipexError {
105
+ constructor(message, options) {
106
+ super('BUNDLE_ERROR', message, options);
107
+ this.name = 'BundleError';
108
+ }
109
+ }
110
+ // -- Kit errors --------------------------------------------------------------
111
+ export class KitError extends PipexError {
112
+ constructor(code, message, options) {
113
+ super(code, message, options);
114
+ this.name = 'KitError';
115
+ }
116
+ }
117
+ export class MissingParameterError extends KitError {
118
+ constructor(kitName, paramName, options) {
119
+ super('MISSING_PARAMETER', `Kit "${kitName}": "${paramName}" parameter is required`, options);
120
+ this.name = 'MissingParameterError';
121
+ }
122
+ }
package/dist/index.js CHANGED
@@ -38,3 +38,6 @@
38
38
  */
39
39
  // Export engine layer for programmatic use
40
40
  export { Workspace, ContainerExecutor, DockerCliExecutor } from './engine/index.js';
41
+ // Export core layer for pipeline orchestration
42
+ export { PipelineRunner, StepRunner, PipelineLoader, StateManager, ConsoleReporter, StreamReporter, CompositeReporter, InMemoryTransport, EventAggregator, buildGraph, topologicalLevels, subgraph, leafNodes, evaluateCondition, dirSize, formatSize, formatDuration } from './core/index.js';
43
+ export { PipexError, DockerError, DockerNotAvailableError, ImagePullError, ContainerTimeoutError, ContainerCrashError, ContainerCleanupError, WorkspaceError, ArtifactNotFoundError, StagingError, PipelineError, ValidationError, CyclicDependencyError, StepNotFoundError, KitError, MissingParameterError } from './errors.js';
@@ -0,0 +1,23 @@
1
+ import test from 'ava';
2
+ import { KitError } from '../../errors.js';
3
+ import { getKit } from '../index.js';
4
+ test('getKit returns node kit', t => {
5
+ const kit = getKit('node');
6
+ t.is(kit.name, 'node');
7
+ });
8
+ test('getKit returns python kit', t => {
9
+ const kit = getKit('python');
10
+ t.is(kit.name, 'python');
11
+ });
12
+ test('getKit returns shell kit', t => {
13
+ const kit = getKit('shell');
14
+ t.is(kit.name, 'shell');
15
+ });
16
+ test('getKit throws KitError on unknown kit with available list', t => {
17
+ const error = t.throws(() => getKit('unknown'));
18
+ t.true(error instanceof KitError);
19
+ t.truthy(error?.message.includes('Unknown kit'));
20
+ t.truthy(error?.message.includes('node'));
21
+ t.truthy(error?.message.includes('python'));
22
+ t.truthy(error?.message.includes('shell'));
23
+ });