@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.
- package/dist/src/dataflow/api-compat.d.ts.map +1 -1
- package/dist/src/dataflow/api-compat.js +6 -1
- package/dist/src/dataflow/api-compat.js.map +1 -1
- package/dist/src/dataflow/orchestrator/LocalOrchestrator.d.ts +22 -4
- package/dist/src/dataflow/orchestrator/LocalOrchestrator.d.ts.map +1 -1
- package/dist/src/dataflow/orchestrator/LocalOrchestrator.js +353 -79
- package/dist/src/dataflow/orchestrator/LocalOrchestrator.js.map +1 -1
- package/dist/src/dataflow/orchestrator/interfaces.d.ts +6 -0
- package/dist/src/dataflow/orchestrator/interfaces.d.ts.map +1 -1
- package/dist/src/dataflow/orchestrator/interfaces.js +1 -0
- package/dist/src/dataflow/orchestrator/interfaces.js.map +1 -1
- package/dist/src/dataflow/steps.d.ts +74 -28
- package/dist/src/dataflow/steps.d.ts.map +1 -1
- package/dist/src/dataflow/steps.js +221 -42
- package/dist/src/dataflow/steps.js.map +1 -1
- package/dist/src/dataflow/types.d.ts +13 -2
- package/dist/src/dataflow/types.d.ts.map +1 -1
- package/dist/src/dataflow.d.ts +37 -95
- package/dist/src/dataflow.d.ts.map +1 -1
- package/dist/src/dataflow.js +121 -631
- package/dist/src/dataflow.js.map +1 -1
- package/dist/src/dataset-refs.d.ts +124 -0
- package/dist/src/dataset-refs.d.ts.map +1 -0
- package/dist/src/dataset-refs.js +319 -0
- package/dist/src/dataset-refs.js.map +1 -0
- package/dist/src/execution/MockTaskRunner.d.ts +1 -1
- package/dist/src/execution/MockTaskRunner.d.ts.map +1 -1
- package/dist/src/execution/MockTaskRunner.js +1 -2
- package/dist/src/execution/MockTaskRunner.js.map +1 -1
- package/dist/src/index.d.ts +5 -4
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +6 -4
- package/dist/src/index.js.map +1 -1
- package/dist/src/packages.d.ts.map +1 -1
- package/dist/src/packages.js +20 -7
- package/dist/src/packages.js.map +1 -1
- package/dist/src/storage/in-memory/InMemoryStorage.d.ts +26 -4
- package/dist/src/storage/in-memory/InMemoryStorage.d.ts.map +1 -1
- package/dist/src/storage/in-memory/InMemoryStorage.js +104 -21
- package/dist/src/storage/in-memory/InMemoryStorage.js.map +1 -1
- package/dist/src/storage/index.d.ts +2 -2
- package/dist/src/storage/index.d.ts.map +1 -1
- package/dist/src/storage/index.js +1 -1
- package/dist/src/storage/index.js.map +1 -1
- package/dist/src/storage/interfaces.d.ts +52 -1
- package/dist/src/storage/interfaces.d.ts.map +1 -1
- package/dist/src/storage/local/LocalBackend.d.ts +3 -1
- package/dist/src/storage/local/LocalBackend.d.ts.map +1 -1
- package/dist/src/storage/local/LocalBackend.js +5 -1
- package/dist/src/storage/local/LocalBackend.js.map +1 -1
- package/dist/src/storage/local/LocalDatasetRefStore.d.ts +22 -0
- package/dist/src/storage/local/LocalDatasetRefStore.d.ts.map +1 -0
- package/dist/src/storage/local/LocalDatasetRefStore.js +118 -0
- package/dist/src/storage/local/LocalDatasetRefStore.js.map +1 -0
- package/dist/src/storage/local/LocalLockService.d.ts +6 -0
- package/dist/src/storage/local/LocalLockService.d.ts.map +1 -1
- package/dist/src/storage/local/LocalLockService.js +17 -4
- package/dist/src/storage/local/LocalLockService.js.map +1 -1
- package/dist/src/storage/local/LocalRepoStore.d.ts +4 -2
- package/dist/src/storage/local/LocalRepoStore.d.ts.map +1 -1
- package/dist/src/storage/local/LocalRepoStore.js +14 -2
- package/dist/src/storage/local/LocalRepoStore.js.map +1 -1
- package/dist/src/storage/local/gc.d.ts.map +1 -1
- package/dist/src/storage/local/gc.js +8 -1
- package/dist/src/storage/local/gc.js.map +1 -1
- package/dist/src/storage/local/index.d.ts +1 -0
- package/dist/src/storage/local/index.d.ts.map +1 -1
- package/dist/src/storage/local/index.js +1 -0
- package/dist/src/storage/local/index.js.map +1 -1
- package/dist/src/trees.d.ts +35 -43
- package/dist/src/trees.d.ts.map +1 -1
- package/dist/src/trees.js +228 -449
- package/dist/src/trees.js.map +1 -1
- package/dist/src/workspaces.d.ts +6 -27
- package/dist/src/workspaces.d.ts.map +1 -1
- package/dist/src/workspaces.js +42 -55
- package/dist/src/workspaces.js.map +1 -1
- 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 {
|
|
14
|
-
import {
|
|
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
|
|
27
|
+
* Simple async mutex to serialize state mutations.
|
|
20
28
|
*
|
|
21
|
-
* When multiple tasks complete concurrently, their
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
* -
|
|
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
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
*
|
|
200
|
-
*
|
|
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
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
//
|
|
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 (
|
|
260
|
-
// Handle task completion
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
//
|
|
341
|
-
|
|
342
|
-
|
|
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
|
*/
|