@elaraai/e3-core 0.0.2-beta.8 → 1.0.0

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 (285) hide show
  1. package/LICENSE.md +4 -0
  2. package/README.md +74 -35
  3. package/dist/src/dataflow/api-compat.d.ts +90 -0
  4. package/dist/src/dataflow/api-compat.d.ts.map +1 -0
  5. package/dist/src/dataflow/api-compat.js +139 -0
  6. package/dist/src/dataflow/api-compat.js.map +1 -0
  7. package/dist/src/dataflow/api-compat.spec.d.ts +6 -0
  8. package/dist/src/dataflow/api-compat.spec.d.ts.map +1 -0
  9. package/dist/src/dataflow/api-compat.spec.js +182 -0
  10. package/dist/src/dataflow/api-compat.spec.js.map +1 -0
  11. package/dist/src/dataflow/index.d.ts +18 -0
  12. package/dist/src/dataflow/index.d.ts.map +1 -0
  13. package/dist/src/dataflow/index.js +23 -0
  14. package/dist/src/dataflow/index.js.map +1 -0
  15. package/dist/src/dataflow/orchestrator/LocalOrchestrator.d.ts +76 -0
  16. package/dist/src/dataflow/orchestrator/LocalOrchestrator.d.ts.map +1 -0
  17. package/dist/src/dataflow/orchestrator/LocalOrchestrator.js +729 -0
  18. package/dist/src/dataflow/orchestrator/LocalOrchestrator.js.map +1 -0
  19. package/dist/src/dataflow/orchestrator/index.d.ts +12 -0
  20. package/dist/src/dataflow/orchestrator/index.d.ts.map +1 -0
  21. package/dist/src/dataflow/orchestrator/index.js +12 -0
  22. package/dist/src/dataflow/orchestrator/index.js.map +1 -0
  23. package/dist/src/dataflow/orchestrator/interfaces.d.ts +163 -0
  24. package/dist/src/dataflow/orchestrator/interfaces.d.ts.map +1 -0
  25. package/dist/src/dataflow/orchestrator/interfaces.js +52 -0
  26. package/dist/src/dataflow/orchestrator/interfaces.js.map +1 -0
  27. package/dist/src/dataflow/state-store/FileStateStore.d.ts +67 -0
  28. package/dist/src/dataflow/state-store/FileStateStore.d.ts.map +1 -0
  29. package/dist/src/dataflow/state-store/FileStateStore.js +300 -0
  30. package/dist/src/dataflow/state-store/FileStateStore.js.map +1 -0
  31. package/dist/src/dataflow/state-store/InMemoryStateStore.d.ts +42 -0
  32. package/dist/src/dataflow/state-store/InMemoryStateStore.d.ts.map +1 -0
  33. package/dist/src/dataflow/state-store/InMemoryStateStore.js +229 -0
  34. package/dist/src/dataflow/state-store/InMemoryStateStore.js.map +1 -0
  35. package/dist/src/dataflow/state-store/InMemoryStateStore.spec.d.ts +6 -0
  36. package/dist/src/dataflow/state-store/InMemoryStateStore.spec.d.ts.map +1 -0
  37. package/dist/src/dataflow/state-store/InMemoryStateStore.spec.js +114 -0
  38. package/dist/src/dataflow/state-store/InMemoryStateStore.spec.js.map +1 -0
  39. package/dist/src/dataflow/state-store/index.d.ts +13 -0
  40. package/dist/src/dataflow/state-store/index.d.ts.map +1 -0
  41. package/dist/src/dataflow/state-store/index.js +13 -0
  42. package/dist/src/dataflow/state-store/index.js.map +1 -0
  43. package/dist/src/dataflow/state-store/interfaces.d.ts +159 -0
  44. package/dist/src/dataflow/state-store/interfaces.d.ts.map +1 -0
  45. package/dist/src/dataflow/state-store/interfaces.js +6 -0
  46. package/dist/src/dataflow/state-store/interfaces.js.map +1 -0
  47. package/dist/src/dataflow/steps.d.ts +222 -0
  48. package/dist/src/dataflow/steps.d.ts.map +1 -0
  49. package/dist/src/dataflow/steps.js +707 -0
  50. package/dist/src/dataflow/steps.js.map +1 -0
  51. package/dist/src/dataflow/steps.spec.d.ts +6 -0
  52. package/dist/src/dataflow/steps.spec.d.ts.map +1 -0
  53. package/dist/src/dataflow/steps.spec.js +343 -0
  54. package/dist/src/dataflow/steps.spec.js.map +1 -0
  55. package/dist/src/dataflow/types.d.ts +127 -0
  56. package/dist/src/dataflow/types.d.ts.map +1 -0
  57. package/dist/src/dataflow/types.js +7 -0
  58. package/dist/src/dataflow/types.js.map +1 -0
  59. package/dist/src/dataflow-orchestration.spec.d.ts +6 -0
  60. package/dist/src/dataflow-orchestration.spec.d.ts.map +1 -0
  61. package/dist/src/dataflow-orchestration.spec.js +1025 -0
  62. package/dist/src/dataflow-orchestration.spec.js.map +1 -0
  63. package/dist/src/dataflow.d.ts +113 -38
  64. package/dist/src/dataflow.d.ts.map +1 -1
  65. package/dist/src/dataflow.js +269 -416
  66. package/dist/src/dataflow.js.map +1 -1
  67. package/dist/src/dataflow.spec.d.ts +6 -0
  68. package/dist/src/dataflow.spec.d.ts.map +1 -0
  69. package/dist/src/dataflow.spec.js +663 -0
  70. package/dist/src/dataflow.spec.js.map +1 -0
  71. package/dist/src/dataset-refs.d.ts +124 -0
  72. package/dist/src/dataset-refs.d.ts.map +1 -0
  73. package/dist/src/dataset-refs.js +319 -0
  74. package/dist/src/dataset-refs.js.map +1 -0
  75. package/dist/src/errors.d.ts +39 -9
  76. package/dist/src/errors.d.ts.map +1 -1
  77. package/dist/src/errors.js +51 -8
  78. package/dist/src/errors.js.map +1 -1
  79. package/dist/src/errors.spec.d.ts +6 -0
  80. package/dist/src/errors.spec.d.ts.map +1 -0
  81. package/dist/src/errors.spec.js +276 -0
  82. package/dist/src/errors.spec.js.map +1 -0
  83. package/dist/src/execution/LocalTaskRunner.d.ts +73 -0
  84. package/dist/src/execution/LocalTaskRunner.d.ts.map +1 -0
  85. package/dist/src/execution/LocalTaskRunner.js +399 -0
  86. package/dist/src/execution/LocalTaskRunner.js.map +1 -0
  87. package/dist/src/execution/MockTaskRunner.d.ts +49 -0
  88. package/dist/src/execution/MockTaskRunner.d.ts.map +1 -0
  89. package/dist/src/execution/MockTaskRunner.js +54 -0
  90. package/dist/src/execution/MockTaskRunner.js.map +1 -0
  91. package/dist/src/execution/index.d.ts +16 -0
  92. package/dist/src/execution/index.d.ts.map +1 -0
  93. package/dist/src/execution/index.js +8 -0
  94. package/dist/src/execution/index.js.map +1 -0
  95. package/dist/src/execution/interfaces.d.ts +246 -0
  96. package/dist/src/execution/interfaces.d.ts.map +1 -0
  97. package/dist/src/execution/interfaces.js +6 -0
  98. package/dist/src/execution/interfaces.js.map +1 -0
  99. package/dist/src/execution/processHelpers.d.ts +20 -0
  100. package/dist/src/execution/processHelpers.d.ts.map +1 -0
  101. package/dist/src/execution/processHelpers.js +62 -0
  102. package/dist/src/execution/processHelpers.js.map +1 -0
  103. package/dist/src/executions.d.ts +71 -104
  104. package/dist/src/executions.d.ts.map +1 -1
  105. package/dist/src/executions.js +113 -481
  106. package/dist/src/executions.js.map +1 -1
  107. package/dist/src/executions.spec.d.ts +6 -0
  108. package/dist/src/executions.spec.d.ts.map +1 -0
  109. package/dist/src/executions.spec.js +387 -0
  110. package/dist/src/executions.spec.js.map +1 -0
  111. package/dist/src/formats.d.ts +18 -2
  112. package/dist/src/formats.d.ts.map +1 -1
  113. package/dist/src/formats.js +34 -2
  114. package/dist/src/formats.js.map +1 -1
  115. package/dist/src/gc.spec.d.ts +6 -0
  116. package/dist/src/gc.spec.d.ts.map +1 -0
  117. package/dist/src/gc.spec.js +512 -0
  118. package/dist/src/gc.spec.js.map +1 -0
  119. package/dist/src/index.d.ts +20 -10
  120. package/dist/src/index.d.ts.map +1 -1
  121. package/dist/src/index.js +48 -18
  122. package/dist/src/index.js.map +1 -1
  123. package/dist/src/objects.d.ts +7 -53
  124. package/dist/src/objects.d.ts.map +1 -1
  125. package/dist/src/objects.js +13 -232
  126. package/dist/src/objects.js.map +1 -1
  127. package/dist/src/objects.spec.d.ts +6 -0
  128. package/dist/src/objects.spec.d.ts.map +1 -0
  129. package/dist/src/objects.spec.js +247 -0
  130. package/dist/src/objects.spec.js.map +1 -0
  131. package/dist/src/packages.d.ts +41 -14
  132. package/dist/src/packages.d.ts.map +1 -1
  133. package/dist/src/packages.js +151 -89
  134. package/dist/src/packages.js.map +1 -1
  135. package/dist/src/packages.spec.d.ts +6 -0
  136. package/dist/src/packages.spec.d.ts.map +1 -0
  137. package/dist/src/packages.spec.js +324 -0
  138. package/dist/src/packages.spec.js.map +1 -0
  139. package/dist/src/storage/in-memory/InMemoryRepoStore.d.ts +35 -0
  140. package/dist/src/storage/in-memory/InMemoryRepoStore.d.ts.map +1 -0
  141. package/dist/src/storage/in-memory/InMemoryRepoStore.js +107 -0
  142. package/dist/src/storage/in-memory/InMemoryRepoStore.js.map +1 -0
  143. package/dist/src/storage/in-memory/InMemoryRepoStore.spec.d.ts +6 -0
  144. package/dist/src/storage/in-memory/InMemoryRepoStore.spec.d.ts.map +1 -0
  145. package/dist/src/storage/in-memory/InMemoryRepoStore.spec.js +187 -0
  146. package/dist/src/storage/in-memory/InMemoryRepoStore.spec.js.map +1 -0
  147. package/dist/src/storage/in-memory/InMemoryStorage.d.ts +139 -0
  148. package/dist/src/storage/in-memory/InMemoryStorage.d.ts.map +1 -0
  149. package/dist/src/storage/in-memory/InMemoryStorage.js +439 -0
  150. package/dist/src/storage/in-memory/InMemoryStorage.js.map +1 -0
  151. package/dist/src/storage/in-memory/index.d.ts +12 -0
  152. package/dist/src/storage/in-memory/index.d.ts.map +1 -0
  153. package/dist/src/storage/in-memory/index.js +12 -0
  154. package/dist/src/storage/in-memory/index.js.map +1 -0
  155. package/dist/src/storage/index.d.ts +18 -0
  156. package/dist/src/storage/index.d.ts.map +1 -0
  157. package/dist/src/storage/index.js +10 -0
  158. package/dist/src/storage/index.js.map +1 -0
  159. package/dist/src/storage/interfaces.d.ts +581 -0
  160. package/dist/src/storage/interfaces.d.ts.map +1 -0
  161. package/dist/src/storage/interfaces.js +6 -0
  162. package/dist/src/storage/interfaces.js.map +1 -0
  163. package/dist/src/storage/local/LocalBackend.d.ts +56 -0
  164. package/dist/src/storage/local/LocalBackend.d.ts.map +1 -0
  165. package/dist/src/storage/local/LocalBackend.js +145 -0
  166. package/dist/src/storage/local/LocalBackend.js.map +1 -0
  167. package/dist/src/storage/local/LocalDatasetRefStore.d.ts +22 -0
  168. package/dist/src/storage/local/LocalDatasetRefStore.d.ts.map +1 -0
  169. package/dist/src/storage/local/LocalDatasetRefStore.js +118 -0
  170. package/dist/src/storage/local/LocalDatasetRefStore.js.map +1 -0
  171. package/dist/src/storage/local/LocalLockService.d.ts +111 -0
  172. package/dist/src/storage/local/LocalLockService.d.ts.map +1 -0
  173. package/dist/src/storage/local/LocalLockService.js +364 -0
  174. package/dist/src/storage/local/LocalLockService.js.map +1 -0
  175. package/dist/src/storage/local/LocalLockService.spec.d.ts +6 -0
  176. package/dist/src/storage/local/LocalLockService.spec.d.ts.map +1 -0
  177. package/dist/src/storage/local/LocalLockService.spec.js +148 -0
  178. package/dist/src/storage/local/LocalLockService.spec.js.map +1 -0
  179. package/dist/src/storage/local/LocalLogStore.d.ts +23 -0
  180. package/dist/src/storage/local/LocalLogStore.d.ts.map +1 -0
  181. package/dist/src/storage/local/LocalLogStore.js +66 -0
  182. package/dist/src/storage/local/LocalLogStore.js.map +1 -0
  183. package/dist/src/storage/local/LocalObjectStore.d.ts +55 -0
  184. package/dist/src/storage/local/LocalObjectStore.d.ts.map +1 -0
  185. package/dist/src/storage/local/LocalObjectStore.js +300 -0
  186. package/dist/src/storage/local/LocalObjectStore.js.map +1 -0
  187. package/dist/src/storage/local/LocalRefStore.d.ts +50 -0
  188. package/dist/src/storage/local/LocalRefStore.d.ts.map +1 -0
  189. package/dist/src/storage/local/LocalRefStore.js +337 -0
  190. package/dist/src/storage/local/LocalRefStore.js.map +1 -0
  191. package/dist/src/storage/local/LocalRepoStore.d.ts +55 -0
  192. package/dist/src/storage/local/LocalRepoStore.d.ts.map +1 -0
  193. package/dist/src/storage/local/LocalRepoStore.js +365 -0
  194. package/dist/src/storage/local/LocalRepoStore.js.map +1 -0
  195. package/dist/src/storage/local/LocalRepoStore.spec.d.ts +6 -0
  196. package/dist/src/storage/local/LocalRepoStore.spec.d.ts.map +1 -0
  197. package/dist/src/storage/local/LocalRepoStore.spec.js +255 -0
  198. package/dist/src/storage/local/LocalRepoStore.spec.js.map +1 -0
  199. package/dist/src/storage/local/gc.d.ts +92 -0
  200. package/dist/src/storage/local/gc.d.ts.map +1 -0
  201. package/dist/src/storage/local/gc.js +377 -0
  202. package/dist/src/storage/local/gc.js.map +1 -0
  203. package/dist/src/storage/local/index.d.ts +18 -0
  204. package/dist/src/storage/local/index.d.ts.map +1 -0
  205. package/dist/src/storage/local/index.js +18 -0
  206. package/dist/src/storage/local/index.js.map +1 -0
  207. package/dist/src/storage/local/localHelpers.d.ts +25 -0
  208. package/dist/src/storage/local/localHelpers.d.ts.map +1 -0
  209. package/dist/src/storage/local/localHelpers.js +69 -0
  210. package/dist/src/storage/local/localHelpers.js.map +1 -0
  211. package/dist/src/{repository.d.ts → storage/local/repository.d.ts} +8 -4
  212. package/dist/src/storage/local/repository.d.ts.map +1 -0
  213. package/dist/src/{repository.js → storage/local/repository.js} +31 -29
  214. package/dist/src/storage/local/repository.js.map +1 -0
  215. package/dist/src/storage/local/repository.spec.d.ts +6 -0
  216. package/dist/src/storage/local/repository.spec.d.ts.map +1 -0
  217. package/dist/src/storage/local/repository.spec.js +186 -0
  218. package/dist/src/storage/local/repository.spec.js.map +1 -0
  219. package/dist/src/tasks.d.ts +16 -10
  220. package/dist/src/tasks.d.ts.map +1 -1
  221. package/dist/src/tasks.js +35 -41
  222. package/dist/src/tasks.js.map +1 -1
  223. package/dist/src/tasks.spec.d.ts +6 -0
  224. package/dist/src/tasks.spec.d.ts.map +1 -0
  225. package/dist/src/tasks.spec.js +105 -0
  226. package/dist/src/tasks.spec.js.map +1 -0
  227. package/dist/src/test-helpers.d.ts +5 -4
  228. package/dist/src/test-helpers.d.ts.map +1 -1
  229. package/dist/src/test-helpers.js +9 -21
  230. package/dist/src/test-helpers.js.map +1 -1
  231. package/dist/src/transfer/InMemoryTransferBackend.d.ts +75 -0
  232. package/dist/src/transfer/InMemoryTransferBackend.d.ts.map +1 -0
  233. package/dist/src/transfer/InMemoryTransferBackend.js +211 -0
  234. package/dist/src/transfer/InMemoryTransferBackend.js.map +1 -0
  235. package/dist/src/transfer/index.d.ts +9 -0
  236. package/dist/src/transfer/index.d.ts.map +1 -0
  237. package/dist/src/transfer/index.js +11 -0
  238. package/dist/src/transfer/index.js.map +1 -0
  239. package/dist/src/transfer/interfaces.d.ts +103 -0
  240. package/dist/src/transfer/interfaces.d.ts.map +1 -0
  241. package/dist/src/transfer/interfaces.js +6 -0
  242. package/dist/src/transfer/interfaces.js.map +1 -0
  243. package/dist/src/transfer/process.d.ts +55 -0
  244. package/dist/src/transfer/process.d.ts.map +1 -0
  245. package/dist/src/transfer/process.js +144 -0
  246. package/dist/src/transfer/process.js.map +1 -0
  247. package/dist/src/transfer/types.d.ts +106 -0
  248. package/dist/src/transfer/types.d.ts.map +1 -0
  249. package/dist/src/transfer/types.js +61 -0
  250. package/dist/src/transfer/types.js.map +1 -0
  251. package/dist/src/trees.d.ts +102 -63
  252. package/dist/src/trees.d.ts.map +1 -1
  253. package/dist/src/trees.js +319 -479
  254. package/dist/src/trees.js.map +1 -1
  255. package/dist/src/trees.spec.d.ts +6 -0
  256. package/dist/src/trees.spec.d.ts.map +1 -0
  257. package/dist/src/trees.spec.js +635 -0
  258. package/dist/src/trees.spec.js.map +1 -0
  259. package/dist/src/uuid.d.ts +26 -0
  260. package/dist/src/uuid.d.ts.map +1 -0
  261. package/dist/src/uuid.js +80 -0
  262. package/dist/src/uuid.js.map +1 -0
  263. package/dist/src/workspaceStatus.d.ts +6 -4
  264. package/dist/src/workspaceStatus.d.ts.map +1 -1
  265. package/dist/src/workspaceStatus.js +46 -60
  266. package/dist/src/workspaceStatus.js.map +1 -1
  267. package/dist/src/workspaces.d.ts +46 -47
  268. package/dist/src/workspaces.d.ts.map +1 -1
  269. package/dist/src/workspaces.js +281 -221
  270. package/dist/src/workspaces.js.map +1 -1
  271. package/dist/src/workspaces.spec.d.ts +6 -0
  272. package/dist/src/workspaces.spec.d.ts.map +1 -0
  273. package/dist/src/workspaces.spec.js +273 -0
  274. package/dist/src/workspaces.spec.js.map +1 -0
  275. package/package.json +15 -15
  276. package/dist/src/gc.d.ts +0 -54
  277. package/dist/src/gc.d.ts.map +0 -1
  278. package/dist/src/gc.js +0 -233
  279. package/dist/src/gc.js.map +0 -1
  280. package/dist/src/repository.d.ts.map +0 -1
  281. package/dist/src/repository.js.map +0 -1
  282. package/dist/src/workspaceLock.d.ts +0 -67
  283. package/dist/src/workspaceLock.d.ts.map +0 -1
  284. package/dist/src/workspaceLock.js +0 -217
  285. package/dist/src/workspaceLock.js.map +0 -1
@@ -0,0 +1,1025 @@
1
+ /**
2
+ * Copyright (c) 2025 Elara AI Pty Ltd
3
+ * Licensed under BSL 1.1. See LICENSE for details.
4
+ */
5
+ /**
6
+ * Tests for dataflow orchestration using MockTaskRunner.
7
+ *
8
+ * These tests verify the dataflow execution logic (dependency ordering,
9
+ * concurrency limits, failure propagation, abort handling, caching)
10
+ * without spawning real processes.
11
+ */
12
+ import { describe, it, beforeEach, afterEach } from 'node:test';
13
+ import assert from 'node:assert';
14
+ import { join } from 'node:path';
15
+ import { mkdirSync, writeFileSync } from 'node:fs';
16
+ import { variant, StringType, ArrayType, encodeBeast2For, East, IRType } from '@elaraai/east';
17
+ import { TaskObjectType, PackageObjectType, } from '@elaraai/e3-types';
18
+ import { dataflowExecute } from './dataflow.js';
19
+ import { datasetWrite } from './trees.js';
20
+ import { objectWrite } from './storage/local/LocalObjectStore.js';
21
+ import { workspaceDeploy } from './workspaces.js';
22
+ import { workspaceSetDataset } from './trees.js';
23
+ import { createTestRepo, removeTestRepo } from './test-helpers.js';
24
+ import { LocalStorage } from './storage/local/index.js';
25
+ import { MockTaskRunner } from './execution/MockTaskRunner.js';
26
+ import { inputsHash } from './executions.js';
27
+ describe('dataflow orchestration with MockTaskRunner', () => {
28
+ let testRepo;
29
+ let storage;
30
+ let mockRunner;
31
+ beforeEach(() => {
32
+ testRepo = createTestRepo();
33
+ storage = new LocalStorage();
34
+ mockRunner = new MockTaskRunner();
35
+ });
36
+ afterEach(() => {
37
+ removeTestRepo(testRepo);
38
+ });
39
+ /**
40
+ * Helper to create a command IR object.
41
+ */
42
+ async function createCommandIr(repoPath, parts) {
43
+ const commandFn = East.function([ArrayType(StringType), StringType], ArrayType(StringType), ($, inputs, output) => {
44
+ const result = [];
45
+ for (const part of parts) {
46
+ if (part === '{input}' || part === '{input0}') {
47
+ result.push(inputs.get(0n));
48
+ }
49
+ else if (part.match(/^\{input(\d+)\}$/)) {
50
+ const idx = BigInt(part.match(/^\{input(\d+)\}$/)[1]);
51
+ result.push(inputs.get(idx));
52
+ }
53
+ else if (part === '{output}') {
54
+ result.push(output);
55
+ }
56
+ else {
57
+ result.push(part);
58
+ }
59
+ }
60
+ return result;
61
+ });
62
+ const ir = commandFn.toIR().ir;
63
+ const encoder = encodeBeast2For(IRType);
64
+ return objectWrite(repoPath, encoder(ir));
65
+ }
66
+ /**
67
+ * Helper to create a package with tasks.
68
+ * Returns a map of task names to task hashes.
69
+ */
70
+ async function createPackageWithTasks(repoPath, tasks, structure) {
71
+ const taskEncoder = encodeBeast2For(TaskObjectType);
72
+ const tasksMap = new Map();
73
+ for (const t of tasks) {
74
+ const commandIrHash = await createCommandIr(repoPath, t.command);
75
+ const taskObj = {
76
+ commandIr: commandIrHash,
77
+ inputs: t.inputs,
78
+ output: t.output,
79
+ kind: variant('none', null), metadata: variant('none', null),
80
+ };
81
+ const taskHash = await objectWrite(repoPath, taskEncoder(taskObj));
82
+ tasksMap.set(t.name, taskHash);
83
+ }
84
+ // Create package object (no root tree — per-dataset refs are used instead)
85
+ const pkgEncoder = encodeBeast2For(PackageObjectType);
86
+ const pkgObj = {
87
+ data: {
88
+ structure,
89
+ refs: new Map(),
90
+ },
91
+ tasks: tasksMap,
92
+ };
93
+ const pkgHash = await objectWrite(repoPath, pkgEncoder(pkgObj));
94
+ const pkgDir = join(repoPath, 'packages', 'test');
95
+ mkdirSync(pkgDir, { recursive: true });
96
+ writeFileSync(join(pkgDir, '1.0.0'), pkgHash + '\n');
97
+ return tasksMap;
98
+ }
99
+ describe('dependency ordering', () => {
100
+ it('executes tasks in topological order', async () => {
101
+ // Create package with A -> B -> C chain
102
+ const structure = {
103
+ type: 'struct',
104
+ value: new Map([
105
+ ['input', { type: 'value', value: { type: StringType, writable: true } }],
106
+ ['middle1', { type: 'value', value: { type: StringType, writable: true } }],
107
+ ['middle2', { type: 'value', value: { type: StringType, writable: true } }],
108
+ ['output', { type: 'value', value: { type: StringType, writable: true } }],
109
+ ]),
110
+ };
111
+ const inputPath = [variant('field', 'input')];
112
+ const middle1Path = [variant('field', 'middle1')];
113
+ const middle2Path = [variant('field', 'middle2')];
114
+ const outputPath = [variant('field', 'output')];
115
+ const inputEncoder = encodeBeast2For(StringType);
116
+ const _inputHash = await objectWrite(testRepo, inputEncoder('test'));
117
+ const taskHashes = await createPackageWithTasks(testRepo, [
118
+ { name: 'task-a', command: ['echo'], inputs: [inputPath], output: middle1Path },
119
+ { name: 'task-b', command: ['echo'], inputs: [middle1Path], output: middle2Path },
120
+ { name: 'task-c', command: ['echo'], inputs: [middle2Path], output: outputPath },
121
+ ], structure);
122
+ await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
123
+ await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
124
+ // Configure mock to return unique output hashes
125
+ for (const [name, hash] of taskHashes) {
126
+ mockRunner.setResult(hash, {
127
+ state: 'success',
128
+ cached: false,
129
+ outputHash: `output-${name}`,
130
+ });
131
+ }
132
+ const completedOrder = [];
133
+ await dataflowExecute(storage, testRepo, 'test-ws', {
134
+ runner: mockRunner,
135
+ onTaskComplete: (r) => completedOrder.push(r.name),
136
+ });
137
+ // Verify execution order: A must complete before B, B before C
138
+ assert.strictEqual(completedOrder.indexOf('task-a') < completedOrder.indexOf('task-b'), true);
139
+ assert.strictEqual(completedOrder.indexOf('task-b') < completedOrder.indexOf('task-c'), true);
140
+ });
141
+ it('executes independent tasks in parallel', async () => {
142
+ // Create package with diamond: A -> B, A -> C, B+C -> D
143
+ const structure = {
144
+ type: 'struct',
145
+ value: new Map([
146
+ ['input', { type: 'value', value: { type: StringType, writable: true } }],
147
+ ['out_a', { type: 'value', value: { type: StringType, writable: true } }],
148
+ ['out_b', { type: 'value', value: { type: StringType, writable: true } }],
149
+ ['out_c', { type: 'value', value: { type: StringType, writable: true } }],
150
+ ]),
151
+ };
152
+ const inputPath = [variant('field', 'input')];
153
+ const inputEncoder = encodeBeast2For(StringType);
154
+ const _inputHash = await objectWrite(testRepo, inputEncoder('test'));
155
+ const taskHashes = await createPackageWithTasks(testRepo, [
156
+ { name: 'task-a', command: ['echo'], inputs: [inputPath], output: [variant('field', 'out_a')] },
157
+ { name: 'task-b', command: ['echo'], inputs: [inputPath], output: [variant('field', 'out_b')] },
158
+ { name: 'task-c', command: ['echo'], inputs: [inputPath], output: [variant('field', 'out_c')] },
159
+ ], structure);
160
+ await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
161
+ await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
162
+ // Configure mock results
163
+ for (const [name, hash] of taskHashes) {
164
+ mockRunner.setResult(hash, {
165
+ state: 'success',
166
+ cached: false,
167
+ outputHash: `output-${name}`,
168
+ });
169
+ }
170
+ const result = await dataflowExecute(storage, testRepo, 'test-ws', {
171
+ runner: mockRunner,
172
+ concurrency: 4,
173
+ });
174
+ assert.strictEqual(result.success, true);
175
+ assert.strictEqual(result.executed, 3);
176
+ // Verify all tasks were called
177
+ const calls = mockRunner.getCalls();
178
+ assert.strictEqual(calls.length, 3);
179
+ });
180
+ });
181
+ describe('concurrency limit', () => {
182
+ it('respects concurrency limit with mock runner', async () => {
183
+ // Create package with 4 independent tasks
184
+ const structure = {
185
+ type: 'struct',
186
+ value: new Map([
187
+ ['input', { type: 'value', value: { type: StringType, writable: true } }],
188
+ ['out1', { type: 'value', value: { type: StringType, writable: true } }],
189
+ ['out2', { type: 'value', value: { type: StringType, writable: true } }],
190
+ ['out3', { type: 'value', value: { type: StringType, writable: true } }],
191
+ ['out4', { type: 'value', value: { type: StringType, writable: true } }],
192
+ ]),
193
+ };
194
+ const inputPath = [variant('field', 'input')];
195
+ const inputEncoder = encodeBeast2For(StringType);
196
+ const _inputHash = await objectWrite(testRepo, inputEncoder('test'));
197
+ const taskHashes = await createPackageWithTasks(testRepo, [
198
+ { name: 'task-1', command: ['echo'], inputs: [inputPath], output: [variant('field', 'out1')] },
199
+ { name: 'task-2', command: ['echo'], inputs: [inputPath], output: [variant('field', 'out2')] },
200
+ { name: 'task-3', command: ['echo'], inputs: [inputPath], output: [variant('field', 'out3')] },
201
+ { name: 'task-4', command: ['echo'], inputs: [inputPath], output: [variant('field', 'out4')] },
202
+ ], structure);
203
+ await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
204
+ await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
205
+ // Configure mock to add delay and track concurrency
206
+ let currentConcurrent = 0;
207
+ let maxConcurrent = 0;
208
+ for (const [name, hash] of taskHashes) {
209
+ mockRunner.setResult(hash, () => {
210
+ currentConcurrent++;
211
+ maxConcurrent = Math.max(maxConcurrent, currentConcurrent);
212
+ // Simulate async work then decrement
213
+ return {
214
+ state: 'success',
215
+ cached: false,
216
+ outputHash: `output-${name}`,
217
+ };
218
+ });
219
+ }
220
+ // Track via callbacks since mock execute is sync
221
+ let startCount = 0;
222
+ await dataflowExecute(storage, testRepo, 'test-ws', {
223
+ runner: mockRunner,
224
+ concurrency: 2,
225
+ onTaskStart: () => {
226
+ startCount++;
227
+ },
228
+ onTaskComplete: () => {
229
+ currentConcurrent--;
230
+ },
231
+ });
232
+ assert.strictEqual(startCount, 4);
233
+ // Note: With synchronous mock, concurrency tracking through callbacks works differently
234
+ // The key test is that all tasks were executed
235
+ const calls = mockRunner.getCalls();
236
+ assert.strictEqual(calls.length, 4);
237
+ });
238
+ });
239
+ describe('cache behavior', () => {
240
+ it('counts tasks as cached when runner returns cached: true', async () => {
241
+ const structure = {
242
+ type: 'struct',
243
+ value: new Map([
244
+ ['input', { type: 'value', value: { type: StringType, writable: true } }],
245
+ ['output', { type: 'value', value: { type: StringType, writable: true } }],
246
+ ]),
247
+ };
248
+ const inputPath = [variant('field', 'input')];
249
+ const outputPath = [variant('field', 'output')];
250
+ const inputEncoder = encodeBeast2For(StringType);
251
+ const _inputHash = await objectWrite(testRepo, inputEncoder('test'));
252
+ const taskHashes = await createPackageWithTasks(testRepo, [{ name: 'task', command: ['echo'], inputs: [inputPath], output: outputPath }], structure);
253
+ await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
254
+ await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
255
+ // First run: not cached
256
+ for (const [, hash] of taskHashes) {
257
+ mockRunner.setResult(hash, {
258
+ state: 'success',
259
+ cached: false,
260
+ outputHash: 'output-hash',
261
+ });
262
+ }
263
+ const result1 = await dataflowExecute(storage, testRepo, 'test-ws', {
264
+ runner: mockRunner,
265
+ });
266
+ assert.strictEqual(result1.executed, 1);
267
+ assert.strictEqual(result1.cached, 0);
268
+ // Second run: runner returns cached: true
269
+ mockRunner.clearCalls();
270
+ for (const [, hash] of taskHashes) {
271
+ mockRunner.setResult(hash, {
272
+ state: 'success',
273
+ cached: true,
274
+ outputHash: 'output-hash',
275
+ });
276
+ }
277
+ const result2 = await dataflowExecute(storage, testRepo, 'test-ws', {
278
+ runner: mockRunner,
279
+ });
280
+ // Note: The dataflow has its own cache check before calling the runner.
281
+ // If the workspace output already matches the cached output, runner isn't called.
282
+ // In this test, we're verifying that if runner IS called and returns cached: true,
283
+ // it's counted correctly.
284
+ assert.strictEqual(result2.success, true);
285
+ });
286
+ });
287
+ describe('failure propagation', () => {
288
+ it('skips downstream tasks when upstream fails', async () => {
289
+ // Create A -> B -> C, where A fails
290
+ const structure = {
291
+ type: 'struct',
292
+ value: new Map([
293
+ ['input', { type: 'value', value: { type: StringType, writable: true } }],
294
+ ['middle1', { type: 'value', value: { type: StringType, writable: true } }],
295
+ ['middle2', { type: 'value', value: { type: StringType, writable: true } }],
296
+ ['output', { type: 'value', value: { type: StringType, writable: true } }],
297
+ ]),
298
+ };
299
+ const inputPath = [variant('field', 'input')];
300
+ const middle1Path = [variant('field', 'middle1')];
301
+ const middle2Path = [variant('field', 'middle2')];
302
+ const outputPath = [variant('field', 'output')];
303
+ const inputEncoder = encodeBeast2For(StringType);
304
+ const _inputHash = await objectWrite(testRepo, inputEncoder('test'));
305
+ const taskHashes = await createPackageWithTasks(testRepo, [
306
+ { name: 'task-a', command: ['echo'], inputs: [inputPath], output: middle1Path },
307
+ { name: 'task-b', command: ['echo'], inputs: [middle1Path], output: middle2Path },
308
+ { name: 'task-c', command: ['echo'], inputs: [middle2Path], output: outputPath },
309
+ ], structure);
310
+ await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
311
+ await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
312
+ // task-a fails, others should succeed if called
313
+ mockRunner.setResult(taskHashes.get('task-a'), {
314
+ state: 'failed',
315
+ cached: false,
316
+ exitCode: 1,
317
+ });
318
+ mockRunner.setResult(taskHashes.get('task-b'), {
319
+ state: 'success',
320
+ cached: false,
321
+ outputHash: 'output-b',
322
+ });
323
+ mockRunner.setResult(taskHashes.get('task-c'), {
324
+ state: 'success',
325
+ cached: false,
326
+ outputHash: 'output-c',
327
+ });
328
+ const result = await dataflowExecute(storage, testRepo, 'test-ws', {
329
+ runner: mockRunner,
330
+ });
331
+ assert.strictEqual(result.success, false);
332
+ assert.strictEqual(result.failed, 1);
333
+ assert.strictEqual(result.skipped, 2); // B and C should be skipped
334
+ const taskA = result.tasks.find(t => t.name === 'task-a');
335
+ const taskB = result.tasks.find(t => t.name === 'task-b');
336
+ const taskC = result.tasks.find(t => t.name === 'task-c');
337
+ assert.strictEqual(taskA?.state, 'failed');
338
+ assert.strictEqual(taskB?.state, 'skipped');
339
+ assert.strictEqual(taskC?.state, 'skipped');
340
+ // Only task-a should have been called
341
+ const calls = mockRunner.getCalls();
342
+ assert.strictEqual(calls.length, 1);
343
+ assert.strictEqual(calls[0].taskHash, taskHashes.get('task-a'));
344
+ });
345
+ it('handles error state from runner', async () => {
346
+ const structure = {
347
+ type: 'struct',
348
+ value: new Map([
349
+ ['input', { type: 'value', value: { type: StringType, writable: true } }],
350
+ ['output', { type: 'value', value: { type: StringType, writable: true } }],
351
+ ]),
352
+ };
353
+ const inputPath = [variant('field', 'input')];
354
+ const outputPath = [variant('field', 'output')];
355
+ const inputEncoder = encodeBeast2For(StringType);
356
+ const _inputHash = await objectWrite(testRepo, inputEncoder('test'));
357
+ const taskHashes = await createPackageWithTasks(testRepo, [{ name: 'task', command: ['echo'], inputs: [inputPath], output: outputPath }], structure);
358
+ await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
359
+ await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
360
+ // Runner returns error state
361
+ mockRunner.setResult(taskHashes.get('task'), {
362
+ state: 'error',
363
+ cached: false,
364
+ error: 'Internal error',
365
+ });
366
+ const result = await dataflowExecute(storage, testRepo, 'test-ws', {
367
+ runner: mockRunner,
368
+ });
369
+ assert.strictEqual(result.success, false);
370
+ assert.strictEqual(result.failed, 1);
371
+ const task = result.tasks.find(t => t.name === 'task');
372
+ assert.strictEqual(task?.state, 'error');
373
+ assert.strictEqual(task?.error, 'Internal error');
374
+ });
375
+ });
376
+ describe('abort handling', () => {
377
+ it('does not start tasks when signal is pre-aborted', async () => {
378
+ // Create an independent task
379
+ const structure = {
380
+ type: 'struct',
381
+ value: new Map([
382
+ ['input', { type: 'value', value: { type: StringType, writable: true } }],
383
+ ['output', { type: 'value', value: { type: StringType, writable: true } }],
384
+ ]),
385
+ };
386
+ const inputPath = [variant('field', 'input')];
387
+ const outputPath = [variant('field', 'output')];
388
+ const inputEncoder = encodeBeast2For(StringType);
389
+ const _inputHash = await objectWrite(testRepo, inputEncoder('test'));
390
+ const taskHashes = await createPackageWithTasks(testRepo, [{ name: 'task', command: ['echo'], inputs: [inputPath], output: outputPath }], structure);
391
+ await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
392
+ await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
393
+ mockRunner.setResult(taskHashes.get('task'), {
394
+ state: 'success',
395
+ cached: false,
396
+ outputHash: 'output-hash',
397
+ });
398
+ // Pre-abort the signal before execution starts
399
+ const controller = new AbortController();
400
+ controller.abort();
401
+ const { DataflowAbortedError } = await import('./errors.js');
402
+ await assert.rejects(dataflowExecute(storage, testRepo, 'test-ws', {
403
+ runner: mockRunner,
404
+ signal: controller.signal,
405
+ }), (err) => {
406
+ assert.ok(err instanceof DataflowAbortedError);
407
+ return true;
408
+ });
409
+ // No tasks should have been executed since signal was pre-aborted
410
+ const calls = mockRunner.getCalls();
411
+ assert.strictEqual(calls.length, 0, 'No tasks should execute when signal is pre-aborted');
412
+ });
413
+ });
414
+ describe('input hash passing', () => {
415
+ it('passes correct input hashes to runner', async () => {
416
+ const structure = {
417
+ type: 'struct',
418
+ value: new Map([
419
+ ['input', { type: 'value', value: { type: StringType, writable: true } }],
420
+ ['output', { type: 'value', value: { type: StringType, writable: true } }],
421
+ ]),
422
+ };
423
+ const inputPath = [variant('field', 'input')];
424
+ const outputPath = [variant('field', 'output')];
425
+ const inputEncoder = encodeBeast2For(StringType);
426
+ const inputHash = await objectWrite(testRepo, inputEncoder('specific-value'));
427
+ const taskHashes = await createPackageWithTasks(testRepo, [{ name: 'task', command: ['echo'], inputs: [inputPath], output: outputPath }], structure);
428
+ await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
429
+ await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'specific-value', StringType);
430
+ // Set up mock to capture input hashes
431
+ let capturedInputHashes = [];
432
+ mockRunner.setResult(taskHashes.get('task'), (inputHashes) => {
433
+ capturedInputHashes = [...inputHashes];
434
+ return {
435
+ state: 'success',
436
+ cached: false,
437
+ outputHash: 'output-hash',
438
+ };
439
+ });
440
+ await dataflowExecute(storage, testRepo, 'test-ws', {
441
+ runner: mockRunner,
442
+ });
443
+ // Verify the input hash was passed correctly
444
+ assert.strictEqual(capturedInputHashes.length, 1);
445
+ assert.strictEqual(capturedInputHashes[0], inputHash);
446
+ });
447
+ });
448
+ describe('callback invocation', () => {
449
+ it('calls onTaskStart and onTaskComplete callbacks', async () => {
450
+ const structure = {
451
+ type: 'struct',
452
+ value: new Map([
453
+ ['input', { type: 'value', value: { type: StringType, writable: true } }],
454
+ ['output', { type: 'value', value: { type: StringType, writable: true } }],
455
+ ]),
456
+ };
457
+ const inputPath = [variant('field', 'input')];
458
+ const outputPath = [variant('field', 'output')];
459
+ const inputEncoder = encodeBeast2For(StringType);
460
+ const _inputHash = await objectWrite(testRepo, inputEncoder('test'));
461
+ const taskHashes = await createPackageWithTasks(testRepo, [{ name: 'my-task', command: ['echo'], inputs: [inputPath], output: outputPath }], structure);
462
+ await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
463
+ await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
464
+ mockRunner.setResult(taskHashes.get('my-task'), {
465
+ state: 'success',
466
+ cached: false,
467
+ outputHash: 'output-hash',
468
+ });
469
+ const startedTasks = [];
470
+ const completedTasks = [];
471
+ await dataflowExecute(storage, testRepo, 'test-ws', {
472
+ runner: mockRunner,
473
+ onTaskStart: (name) => startedTasks.push(name),
474
+ onTaskComplete: (result) => completedTasks.push(result.name),
475
+ });
476
+ assert.deepStrictEqual(startedTasks, ['my-task']);
477
+ assert.deepStrictEqual(completedTasks, ['my-task']);
478
+ });
479
+ it('passes stdout/stderr callbacks to runner', async () => {
480
+ const structure = {
481
+ type: 'struct',
482
+ value: new Map([
483
+ ['input', { type: 'value', value: { type: StringType, writable: true } }],
484
+ ['output', { type: 'value', value: { type: StringType, writable: true } }],
485
+ ]),
486
+ };
487
+ const inputPath = [variant('field', 'input')];
488
+ const outputPath = [variant('field', 'output')];
489
+ const inputEncoder = encodeBeast2For(StringType);
490
+ const _inputHash = await objectWrite(testRepo, inputEncoder('test'));
491
+ const taskHashes = await createPackageWithTasks(testRepo, [{ name: 'task', command: ['echo'], inputs: [inputPath], output: outputPath }], structure);
492
+ await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
493
+ await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
494
+ // Capture the options passed to runner
495
+ let _capturedOptions;
496
+ mockRunner.setResult(taskHashes.get('task'), (_inputHashes) => {
497
+ _capturedOptions = mockRunner.getCalls()[0]?.options;
498
+ return {
499
+ state: 'success',
500
+ cached: false,
501
+ outputHash: 'output-hash',
502
+ };
503
+ });
504
+ const stdoutCalls = [];
505
+ const stderrCalls = [];
506
+ await dataflowExecute(storage, testRepo, 'test-ws', {
507
+ runner: mockRunner,
508
+ onStdout: (task, data) => stdoutCalls.push({ task, data }),
509
+ onStderr: (task, data) => stderrCalls.push({ task, data }),
510
+ });
511
+ // Verify callbacks were passed to runner's options
512
+ const call = mockRunner.getCalls()[0];
513
+ assert.ok(call.options?.onStdout, 'onStdout should be passed to runner');
514
+ assert.ok(call.options?.onStderr, 'onStderr should be passed to runner');
515
+ });
516
+ });
517
+ describe('reactive dataflow', () => {
518
+ it('reaches fixpoint without re-execution when inputs unchanged', async () => {
519
+ // Normal execution, no input changes → same behavior as before
520
+ const structure = {
521
+ type: 'struct',
522
+ value: new Map([
523
+ ['input', { type: 'value', value: { type: StringType, writable: true } }],
524
+ ['middle', { type: 'value', value: { type: StringType, writable: true } }],
525
+ ['output', { type: 'value', value: { type: StringType, writable: true } }],
526
+ ]),
527
+ };
528
+ const inputPath = [variant('field', 'input')];
529
+ const middlePath = [variant('field', 'middle')];
530
+ const outputPath = [variant('field', 'output')];
531
+ const taskHashes = await createPackageWithTasks(testRepo, [
532
+ { name: 'task-a', command: ['echo'], inputs: [inputPath], output: middlePath },
533
+ { name: 'task-b', command: ['echo'], inputs: [middlePath], output: outputPath },
534
+ ], structure);
535
+ await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
536
+ await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
537
+ for (const [name, hash] of taskHashes) {
538
+ mockRunner.setResult(hash, {
539
+ state: 'success',
540
+ cached: false,
541
+ outputHash: `output-${name}`,
542
+ });
543
+ }
544
+ const result = await dataflowExecute(storage, testRepo, 'test-ws', {
545
+ runner: mockRunner,
546
+ });
547
+ assert.strictEqual(result.success, true);
548
+ assert.strictEqual(result.executed, 2);
549
+ assert.strictEqual(result.reexecuted, 0);
550
+ });
551
+ it('re-executes downstream tasks when input changes during execution', async () => {
552
+ // Setup: input → taskA → output
553
+ // MockTaskRunner for taskA: during execution, write new value to input ref
554
+ // After taskA completes, reactive loop detects change, invalidates taskA
555
+ // taskA re-runs with new input
556
+ const structure = {
557
+ type: 'struct',
558
+ value: new Map([
559
+ ['input', { type: 'value', value: { type: StringType, writable: true } }],
560
+ ['output', { type: 'value', value: { type: StringType, writable: true } }],
561
+ ]),
562
+ };
563
+ const inputPath = [variant('field', 'input')];
564
+ const outputPath = [variant('field', 'output')];
565
+ const taskHashes = await createPackageWithTasks(testRepo, [
566
+ { name: 'task-a', command: ['echo'], inputs: [inputPath], output: outputPath },
567
+ ], structure);
568
+ await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
569
+ await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'initial-value', StringType);
570
+ let callCount = 0;
571
+ const taskAHash = taskHashes.get('task-a');
572
+ mockRunner.setResult(taskAHash, async (_inputHashes) => {
573
+ callCount++;
574
+ if (callCount === 1) {
575
+ // On first execution, simulate a concurrent input change
576
+ const newHash = await datasetWrite(storage, testRepo, 'changed-value', StringType);
577
+ const ref = variant('value', { hash: newHash, versions: new Map() });
578
+ await storage.datasets.write(testRepo, 'test-ws', 'input', ref);
579
+ }
580
+ return {
581
+ state: 'success',
582
+ cached: false,
583
+ outputHash: `output-v${callCount}`,
584
+ };
585
+ });
586
+ const result = await dataflowExecute(storage, testRepo, 'test-ws', {
587
+ runner: mockRunner,
588
+ });
589
+ assert.strictEqual(result.success, true);
590
+ // Should have executed task-a twice: once initially, once after input change
591
+ assert.strictEqual(callCount, 2);
592
+ assert.strictEqual(result.reexecuted, 1);
593
+ // executed counts final unique tasks, not total calls
594
+ assert.strictEqual(result.executed, 1);
595
+ });
596
+ it('re-executes chain when input changes during execution', async () => {
597
+ // Setup: input → taskA → middle → taskB → output
598
+ // During taskA execution, input changes
599
+ // taskA should re-run, then taskB should run with new output
600
+ const structure = {
601
+ type: 'struct',
602
+ value: new Map([
603
+ ['input', { type: 'value', value: { type: StringType, writable: true } }],
604
+ ['middle', { type: 'value', value: { type: StringType, writable: true } }],
605
+ ['output', { type: 'value', value: { type: StringType, writable: true } }],
606
+ ]),
607
+ };
608
+ const inputPath = [variant('field', 'input')];
609
+ const middlePath = [variant('field', 'middle')];
610
+ const outputPath = [variant('field', 'output')];
611
+ const taskHashes = await createPackageWithTasks(testRepo, [
612
+ { name: 'task-a', command: ['echo'], inputs: [inputPath], output: middlePath },
613
+ { name: 'task-b', command: ['echo'], inputs: [middlePath], output: outputPath },
614
+ ], structure);
615
+ await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
616
+ await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'initial-value', StringType);
617
+ let taskACallCount = 0;
618
+ let taskBCallCount = 0;
619
+ const taskAHash = taskHashes.get('task-a');
620
+ const taskBHash = taskHashes.get('task-b');
621
+ mockRunner.setResult(taskAHash, async (_inputHashes) => {
622
+ taskACallCount++;
623
+ if (taskACallCount === 1) {
624
+ // On first execution, simulate a concurrent input change
625
+ const newHash = await datasetWrite(storage, testRepo, 'changed-value', StringType);
626
+ const ref = variant('value', { hash: newHash, versions: new Map() });
627
+ await storage.datasets.write(testRepo, 'test-ws', 'input', ref);
628
+ }
629
+ return {
630
+ state: 'success',
631
+ cached: false,
632
+ outputHash: `middle-v${taskACallCount}`,
633
+ };
634
+ });
635
+ mockRunner.setResult(taskBHash, (_inputHashes) => {
636
+ taskBCallCount++;
637
+ return {
638
+ state: 'success',
639
+ cached: false,
640
+ outputHash: `output-v${taskBCallCount}`,
641
+ };
642
+ });
643
+ const result = await dataflowExecute(storage, testRepo, 'test-ws', {
644
+ runner: mockRunner,
645
+ });
646
+ assert.strictEqual(result.success, true);
647
+ // taskA should run twice (initial + re-execution after input change)
648
+ assert.strictEqual(taskACallCount, 2);
649
+ // taskB should run once (blocked until taskA re-executes, then runs with fresh data)
650
+ assert.strictEqual(taskBCallCount, 1);
651
+ // One re-execution (taskA)
652
+ assert.strictEqual(result.reexecuted, 1);
653
+ });
654
+ it('tracks reexecuted count correctly', async () => {
655
+ const structure = {
656
+ type: 'struct',
657
+ value: new Map([
658
+ ['input', { type: 'value', value: { type: StringType, writable: true } }],
659
+ ['output', { type: 'value', value: { type: StringType, writable: true } }],
660
+ ]),
661
+ };
662
+ const inputPath = [variant('field', 'input')];
663
+ const outputPath = [variant('field', 'output')];
664
+ const taskHashes = await createPackageWithTasks(testRepo, [
665
+ { name: 'task-a', command: ['echo'], inputs: [inputPath], output: outputPath },
666
+ ], structure);
667
+ await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
668
+ await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'v1', StringType);
669
+ let callCount = 0;
670
+ const taskAHash = taskHashes.get('task-a');
671
+ mockRunner.setResult(taskAHash, async (_inputHashes) => {
672
+ callCount++;
673
+ if (callCount <= 2) {
674
+ // First two calls: write a new input value
675
+ const newHash = await datasetWrite(storage, testRepo, `v${callCount + 1}`, StringType);
676
+ const ref = variant('value', { hash: newHash, versions: new Map() });
677
+ await storage.datasets.write(testRepo, 'test-ws', 'input', ref);
678
+ }
679
+ return {
680
+ state: 'success',
681
+ cached: false,
682
+ outputHash: `output-v${callCount}`,
683
+ };
684
+ });
685
+ const result = await dataflowExecute(storage, testRepo, 'test-ws', {
686
+ runner: mockRunner,
687
+ });
688
+ assert.strictEqual(result.success, true);
689
+ // Should execute 3 times total: initial + 2 re-executions
690
+ assert.strictEqual(callCount, 3);
691
+ assert.strictEqual(result.reexecuted, 2);
692
+ });
693
+ it('calls onInputChanged callback', async () => {
694
+ const structure = {
695
+ type: 'struct',
696
+ value: new Map([
697
+ ['input', { type: 'value', value: { type: StringType, writable: true } }],
698
+ ['output', { type: 'value', value: { type: StringType, writable: true } }],
699
+ ]),
700
+ };
701
+ const inputPath = [variant('field', 'input')];
702
+ const outputPath = [variant('field', 'output')];
703
+ const taskHashes = await createPackageWithTasks(testRepo, [
704
+ { name: 'task-a', command: ['echo'], inputs: [inputPath], output: outputPath },
705
+ ], structure);
706
+ await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
707
+ await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'initial', StringType);
708
+ let callCount = 0;
709
+ const taskAHash = taskHashes.get('task-a');
710
+ mockRunner.setResult(taskAHash, async (_inputHashes) => {
711
+ callCount++;
712
+ if (callCount === 1) {
713
+ const newHash = await datasetWrite(storage, testRepo, 'changed', StringType);
714
+ const ref = variant('value', { hash: newHash, versions: new Map() });
715
+ await storage.datasets.write(testRepo, 'test-ws', 'input', ref);
716
+ }
717
+ return {
718
+ state: 'success',
719
+ cached: false,
720
+ outputHash: `output-v${callCount}`,
721
+ };
722
+ });
723
+ const inputChanges = [];
724
+ await dataflowExecute(storage, testRepo, 'test-ws', {
725
+ runner: mockRunner,
726
+ onInputChanged: (path, previousHash, newHash) => {
727
+ inputChanges.push({ path, previousHash, newHash });
728
+ },
729
+ });
730
+ assert.strictEqual(inputChanges.length, 1);
731
+ assert.strictEqual(inputChanges[0].path, '.input');
732
+ assert.ok(inputChanges[0].previousHash.length > 0);
733
+ assert.ok(inputChanges[0].newHash.length > 0);
734
+ assert.notStrictEqual(inputChanges[0].previousHash, inputChanges[0].newHash);
735
+ });
736
+ it('calls onTaskInvalidated callback', async () => {
737
+ const structure = {
738
+ type: 'struct',
739
+ value: new Map([
740
+ ['input', { type: 'value', value: { type: StringType, writable: true } }],
741
+ ['output', { type: 'value', value: { type: StringType, writable: true } }],
742
+ ]),
743
+ };
744
+ const inputPath = [variant('field', 'input')];
745
+ const outputPath = [variant('field', 'output')];
746
+ const taskHashes = await createPackageWithTasks(testRepo, [
747
+ { name: 'task-a', command: ['echo'], inputs: [inputPath], output: outputPath },
748
+ ], structure);
749
+ await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
750
+ await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'initial', StringType);
751
+ let callCount = 0;
752
+ const taskAHash = taskHashes.get('task-a');
753
+ mockRunner.setResult(taskAHash, async (_inputHashes) => {
754
+ callCount++;
755
+ if (callCount === 1) {
756
+ const newHash = await datasetWrite(storage, testRepo, 'changed', StringType);
757
+ const ref = variant('value', { hash: newHash, versions: new Map() });
758
+ await storage.datasets.write(testRepo, 'test-ws', 'input', ref);
759
+ }
760
+ return {
761
+ state: 'success',
762
+ cached: false,
763
+ outputHash: `output-v${callCount}`,
764
+ };
765
+ });
766
+ const invalidated = [];
767
+ await dataflowExecute(storage, testRepo, 'test-ws', {
768
+ runner: mockRunner,
769
+ onTaskInvalidated: (name, reason) => {
770
+ invalidated.push({ name, reason });
771
+ },
772
+ });
773
+ assert.strictEqual(invalidated.length, 1);
774
+ assert.strictEqual(invalidated[0].name, 'task-a');
775
+ assert.ok(invalidated[0].reason.includes('.input'), `Reason should mention input path, got: ${invalidated[0].reason}`);
776
+ });
777
+ it('handles no-op change (same hash)', async () => {
778
+ // Input "changes" but to same hash value → no invalidation
779
+ const structure = {
780
+ type: 'struct',
781
+ value: new Map([
782
+ ['input', { type: 'value', value: { type: StringType, writable: true } }],
783
+ ['output', { type: 'value', value: { type: StringType, writable: true } }],
784
+ ]),
785
+ };
786
+ const inputPath = [variant('field', 'input')];
787
+ const outputPath = [variant('field', 'output')];
788
+ const taskHashes = await createPackageWithTasks(testRepo, [
789
+ { name: 'task-a', command: ['echo'], inputs: [inputPath], output: outputPath },
790
+ ], structure);
791
+ await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
792
+ await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'same-value', StringType);
793
+ let callCount = 0;
794
+ const taskAHash = taskHashes.get('task-a');
795
+ mockRunner.setResult(taskAHash, async (_inputHashes) => {
796
+ callCount++;
797
+ if (callCount === 1) {
798
+ // Write the same value — hash should not change
799
+ const sameHash = await datasetWrite(storage, testRepo, 'same-value', StringType);
800
+ const ref = variant('value', { hash: sameHash, versions: new Map() });
801
+ await storage.datasets.write(testRepo, 'test-ws', 'input', ref);
802
+ }
803
+ return {
804
+ state: 'success',
805
+ cached: false,
806
+ outputHash: `output-v${callCount}`,
807
+ };
808
+ });
809
+ const result = await dataflowExecute(storage, testRepo, 'test-ws', {
810
+ runner: mockRunner,
811
+ });
812
+ assert.strictEqual(result.success, true);
813
+ // Should NOT re-execute since the hash is the same
814
+ assert.strictEqual(callCount, 1);
815
+ assert.strictEqual(result.reexecuted, 0);
816
+ });
817
+ });
818
+ describe('DataflowRun recording', () => {
819
+ it('records correct outputVersions with task output hashes', async () => {
820
+ const structure = {
821
+ type: 'struct',
822
+ value: new Map([
823
+ ['input', { type: 'value', value: { type: StringType, writable: true } }],
824
+ ['output', { type: 'value', value: { type: StringType, writable: true } }],
825
+ ]),
826
+ };
827
+ const inputPath = [variant('field', 'input')];
828
+ const outputPath = [variant('field', 'output')];
829
+ const taskHashes = await createPackageWithTasks(testRepo, [{ name: 'task-a', command: ['echo'], inputs: [inputPath], output: outputPath }], structure);
830
+ await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
831
+ await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
832
+ mockRunner.setResult(taskHashes.get('task-a'), {
833
+ state: 'success',
834
+ cached: false,
835
+ outputHash: 'task-a-output-hash',
836
+ });
837
+ const result = await dataflowExecute(storage, testRepo, 'test-ws', {
838
+ runner: mockRunner,
839
+ });
840
+ assert.strictEqual(result.success, true);
841
+ const run = await storage.refs.dataflowRunGetLatest(testRepo, 'test-ws');
842
+ assert.ok(run, 'DataflowRun should exist');
843
+ assert.strictEqual(run.outputVersions.type, 'some');
844
+ const outputVersions = run.outputVersions.value;
845
+ assert.strictEqual(outputVersions.get('.output'), 'task-a-output-hash');
846
+ assert.strictEqual(outputVersions.has('.input'), false, 'Input should not appear in outputVersions');
847
+ });
848
+ it('records outputVersions for all completed tasks in a chain', async () => {
849
+ const structure = {
850
+ type: 'struct',
851
+ value: new Map([
852
+ ['input', { type: 'value', value: { type: StringType, writable: true } }],
853
+ ['middle', { type: 'value', value: { type: StringType, writable: true } }],
854
+ ['output', { type: 'value', value: { type: StringType, writable: true } }],
855
+ ]),
856
+ };
857
+ const inputPath = [variant('field', 'input')];
858
+ const middlePath = [variant('field', 'middle')];
859
+ const outputPath = [variant('field', 'output')];
860
+ const taskHashes = await createPackageWithTasks(testRepo, [
861
+ { name: 'task-a', command: ['echo'], inputs: [inputPath], output: middlePath },
862
+ { name: 'task-b', command: ['echo'], inputs: [middlePath], output: outputPath },
863
+ ], structure);
864
+ await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
865
+ await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
866
+ mockRunner.setResult(taskHashes.get('task-a'), {
867
+ state: 'success',
868
+ cached: false,
869
+ outputHash: 'middle-hash',
870
+ });
871
+ mockRunner.setResult(taskHashes.get('task-b'), {
872
+ state: 'success',
873
+ cached: false,
874
+ outputHash: 'output-hash',
875
+ });
876
+ const result = await dataflowExecute(storage, testRepo, 'test-ws', {
877
+ runner: mockRunner,
878
+ });
879
+ assert.strictEqual(result.success, true);
880
+ const run = await storage.refs.dataflowRunGetLatest(testRepo, 'test-ws');
881
+ assert.ok(run, 'DataflowRun should exist');
882
+ assert.strictEqual(run.outputVersions.type, 'some');
883
+ const outputVersions = run.outputVersions.value;
884
+ assert.strictEqual(outputVersions.get('.middle'), 'middle-hash');
885
+ assert.strictEqual(outputVersions.get('.output'), 'output-hash');
886
+ });
887
+ it('records partial outputVersions when execution is cancelled', async () => {
888
+ const structure = {
889
+ type: 'struct',
890
+ value: new Map([
891
+ ['input', { type: 'value', value: { type: StringType, writable: true } }],
892
+ ['output', { type: 'value', value: { type: StringType, writable: true } }],
893
+ ]),
894
+ };
895
+ const inputPath = [variant('field', 'input')];
896
+ const outputPath = [variant('field', 'output')];
897
+ const taskHashes = await createPackageWithTasks(testRepo, [{ name: 'task-a', command: ['echo'], inputs: [inputPath], output: outputPath }], structure);
898
+ await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
899
+ await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
900
+ const controller = new AbortController();
901
+ // Task aborts during execution
902
+ mockRunner.setResult(taskHashes.get('task-a'), async () => {
903
+ controller.abort();
904
+ // Small delay to let abort propagate
905
+ await new Promise(resolve => setTimeout(resolve, 10));
906
+ return {
907
+ state: 'success',
908
+ cached: false,
909
+ outputHash: 'task-a-output-hash',
910
+ };
911
+ });
912
+ const { DataflowAbortedError } = await import('./errors.js');
913
+ await assert.rejects(dataflowExecute(storage, testRepo, 'test-ws', {
914
+ runner: mockRunner,
915
+ signal: controller.signal,
916
+ }), (err) => {
917
+ assert.ok(err instanceof DataflowAbortedError);
918
+ return true;
919
+ });
920
+ const run = await storage.refs.dataflowRunGetLatest(testRepo, 'test-ws');
921
+ assert.ok(run, 'DataflowRun should exist after cancellation');
922
+ assert.strictEqual(run.status.type, 'cancelled');
923
+ assert.strictEqual(run.outputVersions.type, 'some');
924
+ // Input should not appear in outputVersions even on cancellation
925
+ assert.strictEqual(run.outputVersions.value.has('.input'), false);
926
+ });
927
+ });
928
+ describe('abort cleanup', () => {
929
+ it('removes abort listener after normal completion', async () => {
930
+ const structure = {
931
+ type: 'struct',
932
+ value: new Map([
933
+ ['input', { type: 'value', value: { type: StringType, writable: true } }],
934
+ ['output', { type: 'value', value: { type: StringType, writable: true } }],
935
+ ]),
936
+ };
937
+ const inputPath = [variant('field', 'input')];
938
+ const outputPath = [variant('field', 'output')];
939
+ const taskHashes = await createPackageWithTasks(testRepo, [{ name: 'task', command: ['echo'], inputs: [inputPath], output: outputPath }], structure);
940
+ await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
941
+ await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
942
+ mockRunner.setResult(taskHashes.get('task'), {
943
+ state: 'success',
944
+ cached: false,
945
+ outputHash: 'output-hash',
946
+ });
947
+ const controller = new AbortController();
948
+ const result = await dataflowExecute(storage, testRepo, 'test-ws', {
949
+ runner: mockRunner,
950
+ signal: controller.signal,
951
+ });
952
+ assert.strictEqual(result.success, true);
953
+ // After execution completes, aborting should not throw.
954
+ // If the abort listener were still attached, it could attempt to write
955
+ // to a cleaned-up state store and throw.
956
+ assert.doesNotThrow(() => controller.abort());
957
+ });
958
+ });
959
+ describe('cache-hit mutex', () => {
960
+ it('correctly handles cache hit during concurrent execution', async () => {
961
+ const structure = {
962
+ type: 'struct',
963
+ value: new Map([
964
+ ['input', { type: 'value', value: { type: StringType, writable: true } }],
965
+ ['out_a', { type: 'value', value: { type: StringType, writable: true } }],
966
+ ['out_b', { type: 'value', value: { type: StringType, writable: true } }],
967
+ ]),
968
+ };
969
+ const inputPath = [variant('field', 'input')];
970
+ const taskHashes = await createPackageWithTasks(testRepo, [
971
+ { name: 'task-a', command: ['echo'], inputs: [inputPath], output: [variant('field', 'out_a')] },
972
+ { name: 'task-b', command: ['echo'], inputs: [inputPath], output: [variant('field', 'out_b')] },
973
+ ], structure);
974
+ await workspaceDeploy(storage, testRepo, 'test-ws', 'test', '1.0.0');
975
+ await workspaceSetDataset(storage, testRepo, 'test-ws', inputPath, 'test', StringType);
976
+ // First run: both tasks execute and capture input hashes
977
+ const capturedInputHashes = new Map();
978
+ for (const [name, hash] of taskHashes) {
979
+ mockRunner.setResult(hash, (inputHashesArr) => {
980
+ capturedInputHashes.set(name, [...inputHashesArr]);
981
+ return {
982
+ state: 'success',
983
+ cached: false,
984
+ outputHash: `output-${name}`,
985
+ };
986
+ });
987
+ }
988
+ const result1 = await dataflowExecute(storage, testRepo, 'test-ws', {
989
+ runner: mockRunner,
990
+ concurrency: 4,
991
+ });
992
+ assert.strictEqual(result1.success, true);
993
+ assert.strictEqual(result1.executed, 2);
994
+ // Write execution cache entries so the second run finds cached outputs.
995
+ // The orchestrator's stepPrepareTask checks the execution store.
996
+ // The executionId must be UUIDv7 format for LocalRefStore to find it.
997
+ const now = new Date();
998
+ const fakeUuid = '01900000-0000-7000-8000-000000000001';
999
+ for (const [name, hash] of taskHashes) {
1000
+ const captured = capturedInputHashes.get(name);
1001
+ assert.ok(captured, `Should have captured input hashes for ${name}`);
1002
+ const inHash = inputsHash(captured);
1003
+ await storage.refs.executionWrite(testRepo, hash, inHash, fakeUuid, variant('success', {
1004
+ executionId: fakeUuid,
1005
+ inputHashes: captured,
1006
+ outputHash: `output-${name}`,
1007
+ startedAt: now,
1008
+ completedAt: now,
1009
+ }));
1010
+ }
1011
+ // Second run: both tasks should be inline cache hits (workspace output matches)
1012
+ mockRunner.clearCalls();
1013
+ const result2 = await dataflowExecute(storage, testRepo, 'test-ws', {
1014
+ runner: mockRunner,
1015
+ concurrency: 4,
1016
+ });
1017
+ assert.strictEqual(result2.success, true);
1018
+ assert.strictEqual(result2.cached, 2);
1019
+ assert.strictEqual(result2.executed, 0);
1020
+ // MockRunner should not have been called — cache resolved inline
1021
+ assert.strictEqual(mockRunner.getCalls().length, 0);
1022
+ });
1023
+ });
1024
+ });
1025
+ //# sourceMappingURL=dataflow-orchestration.spec.js.map