@elaraai/e3-core 0.0.2-beta.35 → 0.0.2-beta.37

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 (78) hide show
  1. package/dist/src/dataflow/api-compat.d.ts.map +1 -1
  2. package/dist/src/dataflow/api-compat.js +6 -1
  3. package/dist/src/dataflow/api-compat.js.map +1 -1
  4. package/dist/src/dataflow/orchestrator/LocalOrchestrator.d.ts +22 -4
  5. package/dist/src/dataflow/orchestrator/LocalOrchestrator.d.ts.map +1 -1
  6. package/dist/src/dataflow/orchestrator/LocalOrchestrator.js +353 -79
  7. package/dist/src/dataflow/orchestrator/LocalOrchestrator.js.map +1 -1
  8. package/dist/src/dataflow/orchestrator/interfaces.d.ts +6 -0
  9. package/dist/src/dataflow/orchestrator/interfaces.d.ts.map +1 -1
  10. package/dist/src/dataflow/orchestrator/interfaces.js +1 -0
  11. package/dist/src/dataflow/orchestrator/interfaces.js.map +1 -1
  12. package/dist/src/dataflow/steps.d.ts +74 -28
  13. package/dist/src/dataflow/steps.d.ts.map +1 -1
  14. package/dist/src/dataflow/steps.js +221 -42
  15. package/dist/src/dataflow/steps.js.map +1 -1
  16. package/dist/src/dataflow/types.d.ts +13 -2
  17. package/dist/src/dataflow/types.d.ts.map +1 -1
  18. package/dist/src/dataflow.d.ts +37 -95
  19. package/dist/src/dataflow.d.ts.map +1 -1
  20. package/dist/src/dataflow.js +121 -631
  21. package/dist/src/dataflow.js.map +1 -1
  22. package/dist/src/dataset-refs.d.ts +124 -0
  23. package/dist/src/dataset-refs.d.ts.map +1 -0
  24. package/dist/src/dataset-refs.js +319 -0
  25. package/dist/src/dataset-refs.js.map +1 -0
  26. package/dist/src/execution/MockTaskRunner.d.ts +1 -1
  27. package/dist/src/execution/MockTaskRunner.d.ts.map +1 -1
  28. package/dist/src/execution/MockTaskRunner.js +1 -2
  29. package/dist/src/execution/MockTaskRunner.js.map +1 -1
  30. package/dist/src/index.d.ts +5 -4
  31. package/dist/src/index.d.ts.map +1 -1
  32. package/dist/src/index.js +6 -4
  33. package/dist/src/index.js.map +1 -1
  34. package/dist/src/packages.d.ts.map +1 -1
  35. package/dist/src/packages.js +20 -7
  36. package/dist/src/packages.js.map +1 -1
  37. package/dist/src/storage/in-memory/InMemoryStorage.d.ts +26 -4
  38. package/dist/src/storage/in-memory/InMemoryStorage.d.ts.map +1 -1
  39. package/dist/src/storage/in-memory/InMemoryStorage.js +104 -21
  40. package/dist/src/storage/in-memory/InMemoryStorage.js.map +1 -1
  41. package/dist/src/storage/index.d.ts +2 -2
  42. package/dist/src/storage/index.d.ts.map +1 -1
  43. package/dist/src/storage/index.js +1 -1
  44. package/dist/src/storage/index.js.map +1 -1
  45. package/dist/src/storage/interfaces.d.ts +52 -1
  46. package/dist/src/storage/interfaces.d.ts.map +1 -1
  47. package/dist/src/storage/local/LocalBackend.d.ts +3 -1
  48. package/dist/src/storage/local/LocalBackend.d.ts.map +1 -1
  49. package/dist/src/storage/local/LocalBackend.js +5 -1
  50. package/dist/src/storage/local/LocalBackend.js.map +1 -1
  51. package/dist/src/storage/local/LocalDatasetRefStore.d.ts +22 -0
  52. package/dist/src/storage/local/LocalDatasetRefStore.d.ts.map +1 -0
  53. package/dist/src/storage/local/LocalDatasetRefStore.js +118 -0
  54. package/dist/src/storage/local/LocalDatasetRefStore.js.map +1 -0
  55. package/dist/src/storage/local/LocalLockService.d.ts +6 -0
  56. package/dist/src/storage/local/LocalLockService.d.ts.map +1 -1
  57. package/dist/src/storage/local/LocalLockService.js +17 -4
  58. package/dist/src/storage/local/LocalLockService.js.map +1 -1
  59. package/dist/src/storage/local/LocalRepoStore.d.ts +4 -2
  60. package/dist/src/storage/local/LocalRepoStore.d.ts.map +1 -1
  61. package/dist/src/storage/local/LocalRepoStore.js +14 -2
  62. package/dist/src/storage/local/LocalRepoStore.js.map +1 -1
  63. package/dist/src/storage/local/gc.d.ts.map +1 -1
  64. package/dist/src/storage/local/gc.js +8 -1
  65. package/dist/src/storage/local/gc.js.map +1 -1
  66. package/dist/src/storage/local/index.d.ts +1 -0
  67. package/dist/src/storage/local/index.d.ts.map +1 -1
  68. package/dist/src/storage/local/index.js +1 -0
  69. package/dist/src/storage/local/index.js.map +1 -1
  70. package/dist/src/trees.d.ts +35 -43
  71. package/dist/src/trees.d.ts.map +1 -1
  72. package/dist/src/trees.js +228 -449
  73. package/dist/src/trees.js.map +1 -1
  74. package/dist/src/workspaces.d.ts +6 -27
  75. package/dist/src/workspaces.d.ts.map +1 -1
  76. package/dist/src/workspaces.js +42 -55
  77. package/dist/src/workspaces.js.map +1 -1
  78. package/package.json +1 -1
@@ -7,20 +7,30 @@
7
7
  *
8
8
  * Executes dataflow using an async loop with step functions.
9
9
  * This is the default orchestrator for CLI and local API server usage.
10
+ *
11
+ * Supports reactive execution: after each task completes, checks for
12
+ * root input changes. If inputs changed, affected tasks are invalidated
13
+ * and re-executed. Version vector consistency checks defer tasks whose
14
+ * inputs have conflicting provenance (diamond dependency protection).
10
15
  */
11
- import { variant } from '@elaraai/east';
16
+ import { decodeBeast2For, encodeBeast2For, variant } from '@elaraai/east';
17
+ import { WorkspaceStateType } from '@elaraai/e3-types';
12
18
  import { taskExecute } from '../../execution/LocalTaskRunner.js';
13
- import { workspaceSetDatasetByHash } from '../../trees.js';
14
- import { parsePathString } from '../../dataflow.js';
15
- import { WorkspaceLockError, DataflowAbortedError } from '../../errors.js';
19
+ import { WorkspaceLockError, DataflowAbortedError, DataflowError } from '../../errors.js';
20
+ import { uuidv7 } from '../../uuid.js';
16
21
  import { stateToStatus } from './interfaces.js';
17
- import { stepInitialize, stepGetReady, stepPrepareTask, stepTaskStarted, stepTaskCompleted, stepTaskFailed, stepTasksSkipped, stepIsComplete, stepFinalize, stepCancel, } from '../steps.js';
22
+ import { stepInitialize, stepGetReady, stepPrepareTask, stepTaskStarted, stepTaskCompleted, stepTaskFailed, stepTasksSkipped, stepIsComplete, stepFinalize, stepCancel, stepApplyTreeUpdate, stepDetectInputChanges, stepInvalidateTasks, stepCheckVersionConsistency, } from '../steps.js';
23
+ // =============================================================================
24
+ // Async Mutex for State Mutations
25
+ // =============================================================================
18
26
  /**
19
- * Simple async mutex to serialize workspace state updates.
27
+ * Simple async mutex to serialize state mutations.
20
28
  *
21
- * When multiple tasks complete concurrently, their workspace writes must be
22
- * serialized to prevent race conditions (read-modify-write on the workspace
23
- * root hash). This mutex ensures only one update runs at a time.
29
+ * When multiple tasks complete concurrently, their `.then()` callbacks
30
+ * mutate shared DataflowExecutionState. Between `await` points
31
+ * (stepApplyTreeUpdate, handleInputChanges), another callback can run
32
+ * and corrupt counters/version vectors. This mutex ensures only one
33
+ * state mutation runs at a time while task execution itself runs in parallel.
24
34
  */
25
35
  class AsyncMutex {
26
36
  queue = [];
@@ -64,9 +74,11 @@ class AsyncMutex {
64
74
  *
65
75
  * @remarks
66
76
  * - Uses step functions for each operation
67
- * - Serializes workspace writes with AsyncMutex
77
+ * - Per-dataset ref writes are atomic and independent (no mutex needed)
68
78
  * - Supports AbortSignal for cancellation
69
79
  * - Persists state through the provided state store
80
+ * - Reactive: detects input changes after each task, invalidates and
81
+ * re-executes affected tasks until fixpoint
70
82
  */
71
83
  export class LocalOrchestrator {
72
84
  stateStore;
@@ -81,11 +93,34 @@ export class LocalOrchestrator {
81
93
  this.stateStore = stateStore;
82
94
  }
83
95
  async start(storage, repo, workspace, options = {}) {
84
- // Acquire lock if not provided externally
96
+ // Acquire locks if not provided externally.
97
+ // Dual-lock model:
98
+ // - Shared lock on workspace (allows concurrent e3 set)
99
+ // - Exclusive lock on workspace#dataflow (prevents concurrent starts)
85
100
  const externalLock = !!options.lock;
86
- const lock = options.lock ?? await storage.locks.acquire(repo, workspace, variant('dataflow', null));
87
- if (!lock) {
88
- throw new WorkspaceLockError(workspace);
101
+ let sharedLock = null;
102
+ let dataflowLock = null;
103
+ if (externalLock) {
104
+ // Caller's lock serves as shared workspace lock
105
+ sharedLock = options.lock;
106
+ // Still acquire exclusive dataflow lock (prevents concurrent starts)
107
+ dataflowLock = await storage.locks.acquire(repo, `${workspace}#dataflow`, variant('dataflow', null));
108
+ if (!dataflowLock) {
109
+ throw new WorkspaceLockError(workspace);
110
+ }
111
+ }
112
+ else {
113
+ // Acquire shared workspace lock first (coexists with e3 set)
114
+ sharedLock = await storage.locks.acquire(repo, workspace, variant('dataflow', null), { mode: 'shared' });
115
+ if (!sharedLock) {
116
+ throw new WorkspaceLockError(workspace);
117
+ }
118
+ // Acquire exclusive dataflow lock (prevents concurrent starts)
119
+ dataflowLock = await storage.locks.acquire(repo, `${workspace}#dataflow`, variant('dataflow', null));
120
+ if (!dataflowLock) {
121
+ await sharedLock.release();
122
+ throw new WorkspaceLockError(workspace);
123
+ }
89
124
  }
90
125
  try {
91
126
  // Get next execution ID from state store if available
@@ -112,12 +147,15 @@ export class LocalOrchestrator {
112
147
  // Create running execution state
113
148
  const execution = {
114
149
  state,
115
- lock,
150
+ lock: dataflowLock,
151
+ sharedLock,
116
152
  externalLock,
117
153
  options,
118
- mutex: new AsyncMutex(),
119
154
  aborted: false,
120
155
  runningTasks: new Map(),
156
+ mutex: new AsyncMutex(),
157
+ runId: uuidv7(),
158
+ taskExecutions: new Map(),
121
159
  completionPromise,
122
160
  resolveCompletion,
123
161
  rejectCompletion,
@@ -125,17 +163,15 @@ export class LocalOrchestrator {
125
163
  const key = this.executionKey(repo, workspace, executionId);
126
164
  this.executions.set(key, execution);
127
165
  // Listen for abort signal to persist cancellation immediately.
128
- // This ensures the "cancelled" status survives even if the process
129
- // is killed (e.g., repeated Ctrl-C) before the loop can persist.
130
166
  if (options.signal) {
131
167
  const onAbort = () => {
132
168
  execution.aborted = true;
133
169
  if (this.stateStore) {
134
- // Fire-and-forget: best-effort immediate persistence
135
170
  void this.stateStore.updateStatus(repo, workspace, executionId, 'cancelled', { error: 'Execution was cancelled' }).catch(() => { });
136
171
  }
137
172
  };
138
173
  options.signal.addEventListener('abort', onAbort, { once: true });
174
+ execution.abortCleanup = () => options.signal.removeEventListener('abort', onAbort);
139
175
  }
140
176
  // Start the execution loop (non-blocking)
141
177
  this.runExecutionLoop(storage, repo, execution).catch(err => {
@@ -144,9 +180,11 @@ export class LocalOrchestrator {
144
180
  return { id: executionId, repo, workspace };
145
181
  }
146
182
  catch (err) {
147
- // Release lock on initialization failure (if we acquired it)
148
- if (!externalLock) {
149
- await lock.release();
183
+ // Always release the dataflow lock on initialization failure
184
+ await dataflowLock.release();
185
+ // Release shared workspace lock only if we acquired it (not external)
186
+ if (!externalLock && sharedLock) {
187
+ await sharedLock.release();
150
188
  }
151
189
  throw err;
152
190
  }
@@ -181,8 +219,6 @@ export class LocalOrchestrator {
181
219
  throw new Error(`Execution ${handle.id} not found for workspace '${handle.workspace}'`);
182
220
  }
183
221
  execution.aborted = true;
184
- // Persist cancellation immediately so it survives process crashes.
185
- // The execution loop will also detect the abort and clean up gracefully.
186
222
  if (this.stateStore) {
187
223
  await this.stateStore.updateStatus(handle.repo, handle.workspace, handle.id, 'cancelled', { error: 'Execution was cancelled' });
188
224
  }
@@ -194,15 +230,46 @@ export class LocalOrchestrator {
194
230
  return this.stateStore.getEventsSince(handle.repo, handle.workspace, handle.id, sinceSeq);
195
231
  }
196
232
  /**
197
- * Main execution loop.
233
+ * Main execution loop with reactive fixpoint.
198
234
  *
199
- * Uses step functions to execute tasks, managing concurrency and
200
- * workspace state updates.
235
+ * After each task completes, checks for input changes and invalidates
236
+ * affected tasks. Uses version vector consistency checks to defer tasks
237
+ * whose inputs have conflicting provenance. Execution continues until
238
+ * fixpoint (no more ready, running, or deferred tasks).
201
239
  */
202
240
  async runExecutionLoop(storage, repo, execution) {
203
- const { state, options, mutex } = execution;
241
+ const { state, options } = execution;
204
242
  try {
205
243
  let hasFailure = false;
244
+ // Read workspace state for DataflowRun recording
245
+ const wsData = await storage.refs.workspaceRead(repo, state.workspace);
246
+ const wsDecoder = decodeBeast2For(WorkspaceStateType);
247
+ const wsState = wsData && wsData.length > 0 ? wsDecoder(wsData) : null;
248
+ // Cache structure for the entire execution (immutable during execution)
249
+ const structure = wsState ? await this.readStructure(storage, repo, wsState.packageHash) : null;
250
+ // Write initial DataflowRun record
251
+ if (wsState) {
252
+ const initialRun = {
253
+ runId: execution.runId,
254
+ workspaceName: state.workspace,
255
+ packageRef: `${wsState.packageName}@${wsState.packageVersion}`,
256
+ startedAt: state.startedAt,
257
+ completedAt: variant('none', null),
258
+ status: variant('running', {}),
259
+ inputVersions: new Map(state.inputSnapshot),
260
+ outputVersions: variant('none', null),
261
+ taskExecutions: new Map(),
262
+ summary: {
263
+ total: BigInt(state.tasks.size),
264
+ completed: 0n,
265
+ cached: 0n,
266
+ failed: 0n,
267
+ skipped: 0n,
268
+ reexecuted: 0n,
269
+ },
270
+ };
271
+ await storage.refs.dataflowRunWrite(repo, state.workspace, initialRun);
272
+ }
206
273
  // Check for abort signal from options
207
274
  const checkAborted = () => {
208
275
  if (options.signal?.aborted && !execution.aborted) {
@@ -217,6 +284,10 @@ export class LocalOrchestrator {
217
284
  }
218
285
  // Get ready tasks
219
286
  const readyTasks = stepGetReady(state);
287
+ // Track whether any task was completed synchronously (via cache hit)
288
+ // in this iteration. If so, new downstream tasks may have become ready
289
+ // that aren't in the stale readyTasks array.
290
+ let hadSyncCompletion = false;
220
291
  // Launch tasks up to concurrency limit if no failure and not aborted
221
292
  const concurrencyLimit = Number(state.concurrency);
222
293
  while (!hasFailure &&
@@ -228,13 +299,44 @@ export class LocalOrchestrator {
228
299
  if (!taskState || taskState.status === 'in_progress' || taskState.status === 'completed') {
229
300
  continue;
230
301
  }
302
+ // Version vector consistency check before launching
303
+ const vvCheck = stepCheckVersionConsistency(state, taskName);
304
+ if (!vvCheck.consistent) {
305
+ // Defer: inputs have inconsistent versions of the same root input
306
+ const ts = state.tasks.get(taskName);
307
+ if (ts)
308
+ ts.status = 'deferred';
309
+ // Emit task_deferred event
310
+ const mutableState = state;
311
+ mutableState.eventSeq = state.eventSeq + 1n;
312
+ const deferEvent = variant('task_deferred', {
313
+ seq: mutableState.eventSeq,
314
+ timestamp: new Date(),
315
+ task: taskName,
316
+ conflictPath: vvCheck.conflictPath,
317
+ });
318
+ mutableState.events.push(deferEvent);
319
+ options.onTaskDeferred?.(taskName, vvCheck.conflictPath);
320
+ continue;
321
+ }
231
322
  // Prepare task (resolve inputs, check cache)
232
323
  const prepared = await stepPrepareTask(storage, state, taskName);
233
324
  // Check cache
234
325
  if (prepared.cachedOutputHash !== null) {
235
- // Cache hit - handle synchronously within mutex
236
- await mutex.runExclusive(async () => {
326
+ hadSyncCompletion = true;
327
+ // Cache hit — wrap in mutex to serialize with concurrent .then() callbacks
328
+ await execution.mutex.runExclusive(async () => {
329
+ // Write ref with merged VV and update state
330
+ await stepApplyTreeUpdate(storage, repo, state.workspace, prepared.outputPath, prepared.cachedOutputHash, vvCheck.mergedVV);
237
331
  stepTaskCompleted(state, taskName, prepared.cachedOutputHash, true, 0);
332
+ // Track task execution for DataflowRun
333
+ const existingCached = execution.taskExecutions.get(taskName);
334
+ execution.taskExecutions.set(taskName, {
335
+ executionId: state.id,
336
+ cached: true,
337
+ outputVersions: new Map(vvCheck.mergedVV),
338
+ executionCount: (existingCached?.executionCount ?? 0n) + 1n,
339
+ });
238
340
  // Notify callback
239
341
  options.onTaskComplete?.({
240
342
  name: taskName,
@@ -242,7 +344,9 @@ export class LocalOrchestrator {
242
344
  state: 'success',
243
345
  duration: 0,
244
346
  });
245
- // Update state store (events are added by step function)
347
+ // Detect input changes after cached result
348
+ await this.handleInputChanges(storage, state, options, structure);
349
+ // Update state store
246
350
  if (this.stateStore) {
247
351
  await this.stateStore.update(state);
248
352
  }
@@ -256,53 +360,65 @@ export class LocalOrchestrator {
256
360
  }
257
361
  options.onTaskStart?.(taskName);
258
362
  // Launch task execution
259
- const taskPromise = this.executeTask(storage, repo, execution, taskName, prepared).then(async (result) => {
260
- // Handle task completion within mutex
261
- await mutex.runExclusive(async () => {
262
- if (result.state === 'success') {
263
- const outputPath = parsePathString(prepared.outputPath);
264
- if (result.outputHash) {
265
- await workspaceSetDatasetByHash(storage, repo, state.workspace, outputPath, result.outputHash);
266
- }
267
- stepTaskCompleted(state, taskName, result.outputHash ?? '', result.cached, result.duration);
268
- options.onTaskComplete?.({
269
- name: taskName,
270
- cached: result.cached,
271
- state: 'success',
272
- duration: result.duration,
273
- });
363
+ const taskPromise = this.executeTask(storage, repo, execution, taskName, prepared).then(result => execution.mutex.runExclusive(async () => {
364
+ // Handle task completion
365
+ if (result.state === 'success') {
366
+ // Re-check VV consistency (inputs may have changed during execution)
367
+ const postVVCheck = stepCheckVersionConsistency(state, taskName);
368
+ const mergedVV = postVVCheck.consistent
369
+ ? postVVCheck.mergedVV
370
+ : new Map();
371
+ if (result.outputHash) {
372
+ // Write output ref with merged VV
373
+ await stepApplyTreeUpdate(storage, repo, state.workspace, prepared.outputPath, result.outputHash, mergedVV);
274
374
  }
275
- else {
276
- hasFailure = true;
277
- const { result: failedResult } = stepTaskFailed(state, taskName, result.error, result.exitCode, result.duration);
278
- options.onTaskComplete?.({
279
- name: taskName,
280
- cached: false,
281
- state: result.state === 'failed' ? 'failed' : 'error',
282
- error: result.error,
283
- exitCode: result.exitCode,
284
- duration: result.duration,
285
- });
286
- // Skip dependents (events added by step function)
287
- const skipEvents = stepTasksSkipped(state, failedResult.toSkip, taskName);
288
- for (const skipEvent of skipEvents) {
289
- // skipEvents are always task_skipped events
290
- if (skipEvent.type === 'task_skipped') {
291
- options.onTaskComplete?.({
292
- name: skipEvent.value.task,
293
- cached: false,
294
- state: 'skipped',
295
- duration: 0,
296
- });
297
- }
375
+ stepTaskCompleted(state, taskName, result.outputHash ?? '', result.cached, result.duration);
376
+ // Track task execution for DataflowRun
377
+ const existing = execution.taskExecutions.get(taskName);
378
+ execution.taskExecutions.set(taskName, {
379
+ executionId: result.executionId ?? state.id,
380
+ cached: result.cached,
381
+ outputVersions: new Map(mergedVV),
382
+ executionCount: (existing?.executionCount ?? 0n) + 1n,
383
+ });
384
+ options.onTaskComplete?.({
385
+ name: taskName,
386
+ cached: result.cached,
387
+ state: 'success',
388
+ duration: result.duration,
389
+ });
390
+ // Detect input changes after task completion
391
+ await this.handleInputChanges(storage, state, options, structure);
392
+ }
393
+ else {
394
+ hasFailure = true;
395
+ const { result: failedResult } = stepTaskFailed(state, taskName, result.error, result.exitCode, result.duration);
396
+ options.onTaskComplete?.({
397
+ name: taskName,
398
+ cached: false,
399
+ state: result.state === 'failed' ? 'failed' : 'error',
400
+ error: result.error,
401
+ exitCode: result.exitCode,
402
+ duration: result.duration,
403
+ });
404
+ // Skip dependents (events added by step function)
405
+ const skipEvents = stepTasksSkipped(state, failedResult.toSkip, taskName);
406
+ for (const skipEvent of skipEvents) {
407
+ if (skipEvent.type === 'task_skipped') {
408
+ options.onTaskComplete?.({
409
+ name: skipEvent.value.task,
410
+ cached: false,
411
+ state: 'skipped',
412
+ duration: 0,
413
+ });
298
414
  }
299
415
  }
300
- // Update state store
301
- if (this.stateStore) {
302
- await this.stateStore.update(state);
303
- }
304
- });
305
- }).finally(() => {
416
+ }
417
+ // Update state store
418
+ if (this.stateStore) {
419
+ await this.stateStore.update(state);
420
+ }
421
+ })).finally(() => {
306
422
  execution.runningTasks.delete(taskName);
307
423
  });
308
424
  execution.runningTasks.set(taskName, taskPromise);
@@ -311,6 +427,11 @@ export class LocalOrchestrator {
311
427
  if (execution.runningTasks.size > 0) {
312
428
  await Promise.race(execution.runningTasks.values());
313
429
  }
430
+ else if (hadSyncCompletion) {
431
+ // A cached task completed synchronously, which may have made new
432
+ // downstream tasks ready. Continue to re-check at the top of the loop.
433
+ continue;
434
+ }
314
435
  else if (readyTasks.length === 0 || checkAborted() || hasFailure) {
315
436
  break;
316
437
  }
@@ -319,33 +440,158 @@ export class LocalOrchestrator {
319
440
  if (execution.runningTasks.size > 0) {
320
441
  await Promise.all(execution.runningTasks.values());
321
442
  }
443
+ // Check for stuck state: non-terminal tasks remain but none are ready or running.
444
+ // When a filter is active, only the filtered task is relevant — non-filtered
445
+ // tasks are expected to remain pending.
446
+ const filterValue = state.filter.type === 'some' ? state.filter.value : null;
447
+ const stuckTasks = [...state.tasks.entries()]
448
+ .filter(([name, ts]) => {
449
+ if (ts.status !== 'pending' && ts.status !== 'ready' && ts.status !== 'deferred') {
450
+ return false;
451
+ }
452
+ // When a filter is active, non-filtered tasks staying pending is expected
453
+ if (filterValue !== null && name !== filterValue) {
454
+ return false;
455
+ }
456
+ return true;
457
+ })
458
+ .map(([name, ts]) => `${name} (${ts.status})`)
459
+ .join(', ');
460
+ if (stuckTasks.length > 0 && !checkAborted() && !hasFailure) {
461
+ throw new DataflowError(`Dataflow stuck: ${stuckTasks}`);
462
+ }
322
463
  // Check for abort one final time
323
464
  if (checkAborted()) {
324
465
  stepCancel(state, 'Execution was aborted');
325
466
  if (this.stateStore) {
326
467
  await this.stateStore.update(state);
327
468
  }
469
+ // Write cancelled DataflowRun record
470
+ if (wsState) {
471
+ const cancelledRun = {
472
+ runId: execution.runId,
473
+ workspaceName: state.workspace,
474
+ packageRef: `${wsState.packageName}@${wsState.packageVersion}`,
475
+ startedAt: state.startedAt,
476
+ completedAt: variant('some', new Date()),
477
+ status: variant('cancelled', {}),
478
+ inputVersions: new Map(state.inputSnapshot),
479
+ outputVersions: variant('some', this.buildOutputVersions(state)),
480
+ taskExecutions: new Map(execution.taskExecutions),
481
+ summary: {
482
+ total: BigInt(state.tasks.size),
483
+ completed: state.executed + state.cached,
484
+ cached: state.cached,
485
+ failed: state.failed,
486
+ skipped: state.skipped,
487
+ reexecuted: state.reexecuted,
488
+ },
489
+ };
490
+ await storage.refs.dataflowRunWrite(repo, state.workspace, cancelledRun);
491
+ }
328
492
  // Build partial results for abort error
329
493
  const partialResults = this.buildPartialResults(state);
330
494
  throw new DataflowAbortedError(partialResults);
331
495
  }
332
496
  // Finalize (event added by step function)
333
- const { result } = stepFinalize(state);
497
+ const { result } = stepFinalize(state, execution.runId);
334
498
  if (this.stateStore) {
335
499
  await this.stateStore.update(state);
336
500
  }
501
+ // Write final DataflowRun record
502
+ if (wsState) {
503
+ let finalStatus;
504
+ if (!result.success) {
505
+ // Find the failed task for the error record
506
+ const failedTaskEntry = [...state.tasks.entries()]
507
+ .find(([, ts]) => ts.status === 'failed');
508
+ const failedTaskName = failedTaskEntry?.[0] ?? 'unknown';
509
+ const failedError = failedTaskEntry?.[1].error.type === 'some'
510
+ ? failedTaskEntry[1].error.value
511
+ : 'Task failed';
512
+ finalStatus = variant('failed', {
513
+ failedTask: failedTaskName,
514
+ error: failedError,
515
+ });
516
+ }
517
+ else {
518
+ finalStatus = variant('completed', {});
519
+ }
520
+ const finalRun = {
521
+ runId: execution.runId,
522
+ workspaceName: state.workspace,
523
+ packageRef: `${wsState.packageName}@${wsState.packageVersion}`,
524
+ startedAt: state.startedAt,
525
+ completedAt: variant('some', new Date()),
526
+ status: finalStatus,
527
+ inputVersions: new Map(state.inputSnapshot),
528
+ outputVersions: variant('some', this.buildOutputVersions(state)),
529
+ taskExecutions: new Map(execution.taskExecutions),
530
+ summary: {
531
+ total: BigInt(state.tasks.size),
532
+ completed: state.executed + state.cached,
533
+ cached: state.cached,
534
+ failed: state.failed,
535
+ skipped: state.skipped,
536
+ reexecuted: state.reexecuted,
537
+ },
538
+ };
539
+ await storage.refs.dataflowRunWrite(repo, state.workspace, finalRun);
540
+ // Update workspace state with currentRunId on success
541
+ if (result.success) {
542
+ const currentWsData = await storage.refs.workspaceRead(repo, state.workspace);
543
+ if (currentWsData && currentWsData.length > 0) {
544
+ const currentWsState = wsDecoder(currentWsData);
545
+ const updatedWsState = {
546
+ ...currentWsState,
547
+ currentRunId: variant('some', execution.runId),
548
+ };
549
+ const encoder = encodeBeast2For(WorkspaceStateType);
550
+ await storage.refs.workspaceWrite(repo, state.workspace, encoder(updatedWsState));
551
+ }
552
+ }
553
+ }
337
554
  execution.resolveCompletion(result);
338
555
  }
339
556
  finally {
340
- // Release lock if we acquired it
341
- if (!execution.externalLock) {
342
- await execution.lock.release();
557
+ // Remove abort listener to avoid leaking execution object
558
+ execution.abortCleanup?.();
559
+ // Always release the dataflow lock (we always acquire it)
560
+ await execution.lock.release();
561
+ // Release shared workspace lock only if we acquired it (not external)
562
+ if (!execution.externalLock && execution.sharedLock) {
563
+ await execution.sharedLock.release();
343
564
  }
344
565
  // Clean up execution state
345
566
  const key = this.executionKey(repo, state.workspace, state.id);
346
567
  this.executions.delete(key);
347
568
  }
348
569
  }
570
+ /**
571
+ * Detect input changes and invalidate affected tasks.
572
+ *
573
+ * Called after each task completion to implement the reactive loop.
574
+ */
575
+ async handleInputChanges(storage, state, options, structure) {
576
+ const { changes, events: changeEvents } = await stepDetectInputChanges(storage, state, structure);
577
+ // Notify via callbacks
578
+ for (const evt of changeEvents) {
579
+ if (evt.type === 'input_changed') {
580
+ options.onInputChanged?.(evt.value.path, evt.value.previousHash, evt.value.newHash);
581
+ }
582
+ }
583
+ if (changes.length > 0) {
584
+ const mutableState = state;
585
+ const { invalidated, events: invEvents } = stepInvalidateTasks(state, changes);
586
+ // Track re-executions (tasks that were completed and are now invalidated)
587
+ mutableState.reexecuted = state.reexecuted + BigInt(invalidated.length);
588
+ for (const evt of invEvents) {
589
+ if (evt.type === 'task_invalidated') {
590
+ options.onTaskInvalidated?.(evt.value.task, evt.value.reason);
591
+ }
592
+ }
593
+ }
594
+ }
349
595
  /**
350
596
  * Execute a single task.
351
597
  */
@@ -365,6 +611,7 @@ export class LocalOrchestrator {
365
611
  state: result.state,
366
612
  cached: result.cached,
367
613
  outputHash: result.outputHash,
614
+ executionId: result.executionId,
368
615
  exitCode: result.exitCode,
369
616
  error: result.error,
370
617
  duration: Date.now() - startTime,
@@ -376,6 +623,7 @@ export class LocalOrchestrator {
376
623
  state: result.state,
377
624
  cached: result.cached,
378
625
  outputHash: result.outputHash ?? undefined,
626
+ executionId: result.executionId,
379
627
  exitCode: result.exitCode ?? undefined,
380
628
  error: result.error ?? undefined,
381
629
  duration: Date.now() - startTime,
@@ -406,6 +654,32 @@ export class LocalOrchestrator {
406
654
  }
407
655
  return results;
408
656
  }
657
+ /**
658
+ * Build output versions map from completed task states.
659
+ */
660
+ buildOutputVersions(state) {
661
+ const outputVersions = new Map();
662
+ const graph = state.graph.type === 'some' ? state.graph.value : null;
663
+ if (graph) {
664
+ for (const task of graph.tasks) {
665
+ const ts = state.tasks.get(task.name);
666
+ if (ts && ts.outputHash.type === 'some') {
667
+ outputVersions.set(task.output, ts.outputHash.value);
668
+ }
669
+ }
670
+ }
671
+ return outputVersions;
672
+ }
673
+ /**
674
+ * Read workspace structure from storage.
675
+ */
676
+ async readStructure(storage, repo, packageHash) {
677
+ const { PackageObjectType } = await import('@elaraai/e3-types');
678
+ const pkgData = await storage.objects.read(repo, packageHash);
679
+ const pkgDecoder = decodeBeast2For(PackageObjectType);
680
+ const pkgObject = pkgDecoder(Buffer.from(pkgData));
681
+ return pkgObject.data.structure;
682
+ }
409
683
  /**
410
684
  * Generate unique key for an execution.
411
685
  */