@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,4 +1,4 @@
1
- import { access, mkdir, readdir, rename, rm } from 'node:fs/promises';
1
+ import { access, mkdir, readdir, rename, rm, symlink } from 'node:fs/promises';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { join } from 'node:path';
4
4
  /**
@@ -6,18 +6,22 @@ import { join } from 'node:path';
6
6
  *
7
7
  * A workspace provides:
8
8
  * - **staging/**: Temporary write location during execution
9
- * - **artifacts/**: Committed outputs (immutable, read-only)
9
+ * - **runs/**: Committed run outputs (immutable, read-only)
10
10
  * - **caches/**: Persistent read-write caches (shared across steps)
11
11
  * - **state.json**: Managed by orchestration layer (e.g., CLI) for caching
12
12
  *
13
- * ## Artifact Lifecycle
13
+ * ## Run Lifecycle
14
14
  *
15
- * 1. `prepareArtifact()` creates `staging/{artifactId}/`
16
- * 2. Container writes to `staging/{artifactId}/` (mounted as `/output`)
17
- * 3. Success: `commitArtifact()` atomically moves to `artifacts/{artifactId}/`
18
- * OR Failure: `discardArtifact()` deletes `staging/{artifactId}/`
15
+ * Each step execution produces a **run**, a structured directory containing
16
+ * artifacts (files produced by the step), logs (stdout/stderr), and metadata.
19
17
  *
20
- * Artifacts are immutable once committed.
18
+ * 1. `prepareRun()` creates `staging/{runId}/` with `artifacts/` subdirectory
19
+ * 2. Container writes to `staging/{runId}/artifacts/` (mounted as `/output`)
20
+ * 3. Orchestration layer writes logs and metadata to `staging/{runId}/`
21
+ * 4. Success: `commitRun()` atomically moves to `runs/{runId}/`
22
+ * OR Failure: `discardRun()` deletes `staging/{runId}/`
23
+ *
24
+ * Runs are immutable once committed.
21
25
  *
22
26
  * ## Cache Lifecycle
23
27
  *
@@ -30,12 +34,12 @@ import { join } from 'node:path';
30
34
  * @example
31
35
  * ```typescript
32
36
  * const ws = await Workspace.create('/tmp/workdir', 'my-workspace')
33
- * const artifactId = ws.generateArtifactId()
34
- * await ws.prepareArtifact(artifactId)
37
+ * const runId = ws.generateRunId()
38
+ * await ws.prepareRun(runId)
35
39
  * await ws.prepareCache('pnpm-store')
36
40
  * // ... container execution ...
37
- * await ws.commitArtifact(artifactId) // On success
38
- * // OR await ws.discardArtifact(artifactId) // On failure
41
+ * await ws.commitRun(runId) // On success
42
+ * // OR await ws.discardRun(runId) // On failure
39
43
  * ```
40
44
  */
41
45
  export class Workspace {
@@ -49,7 +53,7 @@ export class Workspace {
49
53
  return Workspace.generateId();
50
54
  }
51
55
  /**
52
- * Creates a new workspace with staging, artifacts, and caches directories.
56
+ * Creates a new workspace with staging, runs, and caches directories.
53
57
  * @param workdirRoot - Root directory for all workspaces
54
58
  * @param id - Optional workspace ID (auto-generated if omitted)
55
59
  * @returns Newly created workspace
@@ -58,7 +62,7 @@ export class Workspace {
58
62
  const workspaceId = id ?? Workspace.generateWorkspaceId();
59
63
  const root = join(workdirRoot, workspaceId);
60
64
  await mkdir(join(root, 'staging'), { recursive: true });
61
- await mkdir(join(root, 'artifacts'), { recursive: true });
65
+ await mkdir(join(root, 'runs'), { recursive: true });
62
66
  await mkdir(join(root, 'caches'), { recursive: true });
63
67
  return new Workspace(workspaceId, root);
64
68
  }
@@ -113,62 +117,89 @@ export class Workspace {
113
117
  this.root = root;
114
118
  }
115
119
  /**
116
- * Generates a unique artifact identifier.
117
- * @returns Artifact ID in format: `{timestamp}-{uuid-prefix}`
120
+ * Generates a unique run identifier.
121
+ * @returns Run ID in format: `{timestamp}-{uuid-prefix}`
118
122
  */
119
- generateArtifactId() {
123
+ generateRunId() {
120
124
  return Workspace.generateId();
121
125
  }
122
126
  /**
123
- * Returns the staging directory path for an artifact.
124
- * Staging is used for temporary writes during execution.
125
- * @param artifactId - Artifact identifier
127
+ * Returns the staging directory path for a run.
128
+ * @param runId - Run identifier
126
129
  * @returns Absolute path to staging directory
127
- * @throws If artifact ID is invalid
128
130
  */
129
- stagingPath(artifactId) {
130
- this.validateArtifactId(artifactId);
131
- return join(this.root, 'staging', artifactId);
131
+ runStagingPath(runId) {
132
+ this.validateRunId(runId);
133
+ return join(this.root, 'staging', runId);
134
+ }
135
+ /**
136
+ * Returns the staging artifacts directory path for a run.
137
+ * @param runId - Run identifier
138
+ * @returns Absolute path to staging artifacts directory
139
+ */
140
+ runStagingArtifactsPath(runId) {
141
+ return join(this.runStagingPath(runId), 'artifacts');
132
142
  }
133
143
  /**
134
- * Returns the committed artifact directory path.
135
- * Artifacts are immutable once committed.
136
- * @param artifactId - Artifact identifier
137
- * @returns Absolute path to artifact directory
138
- * @throws If artifact ID is invalid
144
+ * Returns the committed run directory path.
145
+ * @param runId - Run identifier
146
+ * @returns Absolute path to run directory
139
147
  */
140
- artifactPath(artifactId) {
141
- this.validateArtifactId(artifactId);
142
- return join(this.root, 'artifacts', artifactId);
148
+ runPath(runId) {
149
+ this.validateRunId(runId);
150
+ return join(this.root, 'runs', runId);
143
151
  }
144
152
  /**
145
- * Prepares a staging directory for a new artifact.
146
- * @param artifactId - Artifact identifier
153
+ * Returns the artifacts directory path within a committed run.
154
+ * @param runId - Run identifier
155
+ * @returns Absolute path to run artifacts directory
156
+ */
157
+ runArtifactsPath(runId) {
158
+ return join(this.runPath(runId), 'artifacts');
159
+ }
160
+ /**
161
+ * Prepares a staging directory for a new run.
162
+ * Creates both the run directory and its artifacts subdirectory.
163
+ * @param runId - Run identifier
147
164
  * @returns Absolute path to the created staging directory
148
165
  */
149
- async prepareArtifact(artifactId) {
150
- const path = this.stagingPath(artifactId);
166
+ async prepareRun(runId) {
167
+ const path = this.runStagingPath(runId);
151
168
  await mkdir(path, { recursive: true });
169
+ await mkdir(join(path, 'artifacts'), { recursive: true });
152
170
  return path;
153
171
  }
154
172
  /**
155
- * Commits a staging artifact to the artifacts directory.
173
+ * Commits a staging run to the runs directory.
156
174
  * Uses atomic rename operation for consistency.
157
- * @param artifactId - Artifact identifier
175
+ * @param runId - Run identifier
158
176
  */
159
- async commitArtifact(artifactId) {
160
- await rename(this.stagingPath(artifactId), this.artifactPath(artifactId));
177
+ async commitRun(runId) {
178
+ await rename(this.runStagingPath(runId), this.runPath(runId));
161
179
  }
162
180
  /**
163
- * Discards a staging artifact (on execution failure).
164
- * @param artifactId - Artifact identifier
181
+ * Creates a symlink from `step-runs/{stepId}` to the committed run.
182
+ * Replaces any existing symlink for the same step.
183
+ * @param stepId - Step identifier
184
+ * @param runId - Committed run identifier
165
185
  */
166
- async discardArtifact(artifactId) {
167
- await rm(this.stagingPath(artifactId), { recursive: true, force: true });
186
+ async linkRun(stepId, runId) {
187
+ const dir = join(this.root, 'step-runs');
188
+ await mkdir(dir, { recursive: true });
189
+ const linkPath = join(dir, stepId);
190
+ await rm(linkPath, { force: true });
191
+ await symlink(join('..', 'runs', runId), linkPath);
192
+ }
193
+ /**
194
+ * Discards a staging run (on execution failure).
195
+ * @param runId - Run identifier
196
+ */
197
+ async discardRun(runId) {
198
+ await rm(this.runStagingPath(runId), { recursive: true, force: true });
168
199
  }
169
200
  /**
170
201
  * Removes all staging directories.
171
- * Should be called on workspace initialization to clean up incomplete artifacts.
202
+ * Should be called on workspace initialization to clean up incomplete runs.
172
203
  */
173
204
  async cleanupStaging() {
174
205
  const stagingDir = join(this.root, 'staging');
@@ -185,18 +216,34 @@ export class Workspace {
185
216
  }
186
217
  }
187
218
  /**
188
- * Lists all committed artifact IDs in this workspace.
189
- * @returns Array of artifact IDs (directory names in artifacts/)
219
+ * Lists all committed run IDs in this workspace.
220
+ * @returns Array of run IDs (directory names in runs/)
190
221
  */
191
- async listArtifacts() {
222
+ async listRuns() {
192
223
  try {
193
- const entries = await readdir(join(this.root, 'artifacts'), { withFileTypes: true });
224
+ const entries = await readdir(join(this.root, 'runs'), { withFileTypes: true });
194
225
  return entries.filter(e => e.isDirectory()).map(e => e.name);
195
226
  }
196
227
  catch {
197
228
  return [];
198
229
  }
199
230
  }
231
+ /**
232
+ * Removes runs not in the given set of active run IDs.
233
+ * @param activeRunIds - Set of run IDs to keep
234
+ * @returns Number of runs removed
235
+ */
236
+ async pruneRuns(activeRunIds) {
237
+ const allRuns = await this.listRuns();
238
+ let removed = 0;
239
+ for (const runId of allRuns) {
240
+ if (!activeRunIds.has(runId)) {
241
+ await rm(join(this.root, 'runs', runId), { recursive: true, force: true });
242
+ removed++;
243
+ }
244
+ }
245
+ return removed;
246
+ }
200
247
  /**
201
248
  * Returns the cache directory path.
202
249
  * Caches are persistent read-write directories shared across steps.
@@ -233,22 +280,22 @@ export class Workspace {
233
280
  }
234
281
  }
235
282
  /**
236
- * Validates an artifact ID to prevent path traversal attacks.
237
- * @param id - Artifact identifier to validate
238
- * @throws If the artifact ID contains invalid characters or path traversal attempts
283
+ * Validates a run ID to prevent path traversal attacks.
284
+ * @param id - Run identifier to validate
285
+ * @throws If the run ID contains invalid characters or path traversal attempts
239
286
  * @internal
240
287
  */
241
- validateArtifactId(id) {
288
+ validateRunId(id) {
242
289
  if (!/^[\w-]+$/.test(id)) {
243
- throw new Error(`Invalid artifact ID: ${id}. Must contain only alphanumeric characters, dashes, and underscores.`);
290
+ throw new Error(`Invalid run ID: ${id}. Must contain only alphanumeric characters, dashes, and underscores.`);
244
291
  }
245
292
  if (id.includes('..')) {
246
- throw new Error(`Invalid artifact ID: ${id}. Path traversal is not allowed.`);
293
+ throw new Error(`Invalid run ID: ${id}. Path traversal is not allowed.`);
247
294
  }
248
295
  }
249
296
  /**
250
297
  * Validates a cache name to prevent path traversal attacks.
251
- * Same rules as artifact IDs: alphanumeric, dashes, underscores only.
298
+ * Same rules as run IDs: alphanumeric, dashes, underscores only.
252
299
  * @param name - Cache name to validate
253
300
  * @throws If the cache name contains invalid characters or path traversal attempts
254
301
  * @internal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livingdata/pipex",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "description": "Execution engine for containerized pipeline steps",
5
5
  "author": "Jérôme Desboeufs <jerome@livingdata.co>",
6
6
  "type": "module",