@codemation/host 0.1.0 → 0.1.2

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 (70) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/{AppConfigFactory-Ciz9YKWx.js → AppConfigFactory-ByT1D8dM.js} +253 -15
  3. package/dist/{AppConfigFactory-Ciz9YKWx.js.map → AppConfigFactory-ByT1D8dM.js.map} +1 -1
  4. package/dist/{AppConfigFactory-CiBPHleh.d.ts → AppConfigFactory-_fqSok1J.d.ts} +6565 -177
  5. package/dist/{AppContainerFactory-DH88oxpg.js → AppContainerFactory-BRU02PTm.js} +838 -145
  6. package/dist/AppContainerFactory-BRU02PTm.js.map +1 -0
  7. package/dist/{CodemationConfig-DfK1KLvO.d.ts → CodemationConfig-CNfytKR6.d.ts} +2 -2
  8. package/dist/{CodemationConfigNormalizer-BKgIOeLm.d.ts → CodemationConfigNormalizer-BuKWVNEq.d.ts} +2 -2
  9. package/dist/{CodemationConsumerConfigLoader-bdhJsBKt.d.ts → CodemationConsumerConfigLoader-Mv4cywWu.d.ts} +2 -2
  10. package/dist/{CodemationPluginListMerger-BFZeO0WG.d.ts → CodemationPluginListMerger-BD5mR6gK.d.ts} +11 -5
  11. package/dist/{CredentialServices-aKIwHEhf.d.ts → CredentialServices-BQsEtctT.d.ts} +8 -7
  12. package/dist/{CredentialServices-DNb3CZwW.js → CredentialServices-xVxVA9Tq.js} +60 -114
  13. package/dist/CredentialServices-xVxVA9Tq.js.map +1 -0
  14. package/dist/{PublicFrontendBootstrapFactory-6ahaU0XM.d.ts → PublicFrontendBootstrapFactory-kTyAJdHI.d.ts} +2 -2
  15. package/dist/consumer.d.ts +4 -4
  16. package/dist/credentials.d.ts +3 -3
  17. package/dist/credentials.js +1 -1
  18. package/dist/devServerSidecar.d.ts +1 -1
  19. package/dist/{index-BYbzmUwS.d.ts → index-CX752QE9.d.ts} +68 -4
  20. package/dist/index.d.ts +10 -10
  21. package/dist/index.js +5 -5
  22. package/dist/nextServer.d.ts +20 -15
  23. package/dist/nextServer.js +35 -58
  24. package/dist/nextServer.js.map +1 -1
  25. package/dist/{persistenceServer-DL8yBGDU.d.ts → persistenceServer-CLY4qtMo.d.ts} +2 -2
  26. package/dist/{persistenceServer-CuAqL_fF.js → persistenceServer-DMvIOGW8.js} +2 -2
  27. package/dist/{persistenceServer-CuAqL_fF.js.map → persistenceServer-DMvIOGW8.js.map} +1 -1
  28. package/dist/persistenceServer.d.ts +5 -5
  29. package/dist/persistenceServer.js +2 -2
  30. package/dist/{server-EbxQft_X.js → server-ChTCEc6R.js} +4 -4
  31. package/dist/{server-EbxQft_X.js.map → server-ChTCEc6R.js.map} +1 -1
  32. package/dist/{server-BceIfIJf.d.ts → server-DwpcwzFb.d.ts} +6 -5
  33. package/dist/server.d.ts +8 -8
  34. package/dist/server.js +5 -5
  35. package/package.json +5 -5
  36. package/prisma/migrations/20260407140000_run_normalized_persistence/migration.sql +327 -0
  37. package/prisma/migrations/20260407193000_rename_run_projection_to_run_slot_projection/migration.sql +10 -0
  38. package/prisma/migrations.sqlite/20260407140000_run_normalized_persistence/migration.sql +326 -0
  39. package/prisma/migrations.sqlite/20260407193000_rename_run_projection_to_run_slot_projection/migration.sql +38 -0
  40. package/prisma/schema.postgresql.prisma +100 -1
  41. package/prisma/schema.sqlite.prisma +101 -1
  42. package/scripts/integration-database-global-setup.mjs +0 -3
  43. package/src/application/mapping/WorkflowDefinitionMapper.ts +95 -56
  44. package/src/application/mapping/WorkflowPolicyUiPresentationFactory.ts +1 -1
  45. package/src/application/queries/GetWorkflowRunDetailQuery.ts +8 -0
  46. package/src/application/queries/GetWorkflowRunDetailQueryHandler.ts +24 -0
  47. package/src/application/queries/WorkflowQueryHandlers.ts +1 -0
  48. package/src/application/runs/WorkflowRunRetentionPruneScheduler.ts +52 -27
  49. package/src/domain/credentials/WorkflowCredentialNodeResolver.ts +113 -158
  50. package/src/domain/runs/WorkflowRunRepository.ts +7 -1
  51. package/src/infrastructure/persistence/InMemoryWorkflowRunRepository.ts +123 -1
  52. package/src/infrastructure/persistence/PrismaMigrationDeployer.ts +226 -6
  53. package/src/infrastructure/persistence/PrismaWorkflowRunRepository.ts +796 -109
  54. package/src/infrastructure/persistence/generated/prisma-postgresql-client/edge.js +85 -5
  55. package/src/infrastructure/persistence/generated/prisma-postgresql-client/index-browser.js +81 -1
  56. package/src/infrastructure/persistence/generated/prisma-postgresql-client/index.d.ts +7107 -237
  57. package/src/infrastructure/persistence/generated/prisma-postgresql-client/index.js +85 -5
  58. package/src/infrastructure/persistence/generated/prisma-postgresql-client/package.json +1 -1
  59. package/src/infrastructure/persistence/generated/prisma-postgresql-client/schema.prisma +101 -1
  60. package/src/infrastructure/persistence/generated/prisma-sqlite-client/edge.js +85 -5
  61. package/src/infrastructure/persistence/generated/prisma-sqlite-client/index-browser.js +81 -1
  62. package/src/infrastructure/persistence/generated/prisma-sqlite-client/index.d.ts +7104 -242
  63. package/src/infrastructure/persistence/generated/prisma-sqlite-client/index.js +85 -5
  64. package/src/infrastructure/persistence/generated/prisma-sqlite-client/package.json +1 -1
  65. package/src/infrastructure/persistence/generated/prisma-sqlite-client/schema.prisma +101 -1
  66. package/src/presentation/http/ApiPaths.ts +4 -0
  67. package/src/presentation/http/hono/registrars/RunHonoApiRouteRegistrar.ts +1 -0
  68. package/src/presentation/http/routeHandlers/RunHttpRouteHandler.ts +13 -0
  69. package/dist/AppContainerFactory-DH88oxpg.js.map +0 -1
  70. package/dist/CredentialServices-DNb3CZwW.js.map +0 -1
@@ -1,31 +1,78 @@
1
1
  import type {
2
+ ConnectionInvocationRecord,
3
+ ExecutionInstanceDto,
4
+ NodeInputsByPort,
5
+ NodeExecutionSnapshot,
2
6
  NodeId,
3
7
  NodeOutputs,
4
8
  ParentExecutionRef,
9
+ PendingNodeExecution,
10
+ PersistedRunSchedulingState,
5
11
  PersistedRunState,
6
12
  RunId,
7
13
  RunPruneCandidate,
14
+ RunQueueEntry,
8
15
  RunSummary,
16
+ SlotExecutionStateDto,
17
+ WorkflowRunDetailDto,
9
18
  WorkflowExecutionRepository,
10
19
  WorkflowId,
11
20
  } from "@codemation/core";
12
21
  import { inject, injectable } from "@codemation/core";
13
22
  import type { WorkflowRunRepository } from "../../domain/runs/WorkflowRunRepository";
23
+ import type { Prisma } from "./generated/prisma-postgresql-client/client.js";
14
24
  import { PrismaDatabaseClientToken, type PrismaDatabaseClient } from "./PrismaDatabaseClient";
15
25
 
16
- /** JSON blob stored in stateJson: workflowSnapshot, mutableState, pending, queue, outputsByNode, nodeSnapshotsByNodeId, connectionInvocations, engineCounters */
17
- interface StateJsonBlob {
18
- control?: PersistedRunState["control"];
19
- workflowSnapshot?: PersistedRunState["workflowSnapshot"];
20
- mutableState?: PersistedRunState["mutableState"];
21
- policySnapshot?: PersistedRunState["policySnapshot"];
22
- engineCounters?: PersistedRunState["engineCounters"];
23
- pending?: PersistedRunState["pending"];
24
- queue: PersistedRunState["queue"];
25
- outputsByNode: Record<NodeId, NodeOutputs>;
26
- nodeSnapshotsByNodeId: PersistedRunState["nodeSnapshotsByNodeId"];
27
- connectionInvocations?: PersistedRunState["connectionInvocations"];
28
- }
26
+ type ExecutionInstanceRow = {
27
+ instanceId: string;
28
+ runId: string;
29
+ workflowId: string;
30
+ slotNodeId: string;
31
+ workflowNodeId: string;
32
+ kind: string;
33
+ connectionKind: string | null;
34
+ activationId: string | null;
35
+ batchId: string;
36
+ runIndex: number;
37
+ parentInstanceId: string | null;
38
+ status: string;
39
+ queuedAt: string | null;
40
+ startedAt: string | null;
41
+ finishedAt: string | null;
42
+ updatedAt: string;
43
+ itemCount: number;
44
+ inputJson: string | null;
45
+ outputJson: string | null;
46
+ errorJson: string | null;
47
+ inputItemIndicesJson: string | null;
48
+ outputItemCount: number | null;
49
+ successfulItemCount: number | null;
50
+ failedItemCount: number | null;
51
+ usedPinnedOutput: boolean | null;
52
+ };
53
+
54
+ type RunWorkItemRecord = {
55
+ workItemId: string;
56
+ runId: string;
57
+ workflowId: string;
58
+ status: string;
59
+ targetNodeId: string;
60
+ batchId: string;
61
+ queueName: string | null;
62
+ claimToken: string | null;
63
+ availableAt: string;
64
+ enqueuedAt: string;
65
+ itemsIn: number;
66
+ inputsByPortJson: string;
67
+ };
68
+
69
+ type RunSlotProjectionRow = {
70
+ runId: string;
71
+ workflowId: string;
72
+ revision: number;
73
+ updatedAt: string;
74
+ slotStatesJson: string;
75
+ };
29
76
 
30
77
  @injectable()
31
78
  export class PrismaWorkflowRunRepository implements WorkflowRunRepository, WorkflowExecutionRepository {
@@ -44,24 +91,6 @@ export class PrismaWorkflowRunRepository implements WorkflowRunRepository, Workf
44
91
  engineCounters?: PersistedRunState["engineCounters"];
45
92
  }): Promise<void> {
46
93
  const now = new Date().toISOString();
47
- const state: PersistedRunState = {
48
- runId: args.runId,
49
- workflowId: args.workflowId,
50
- startedAt: args.startedAt,
51
- parent: args.parent,
52
- executionOptions: args.executionOptions,
53
- control: args.control,
54
- workflowSnapshot: args.workflowSnapshot,
55
- mutableState: args.mutableState,
56
- policySnapshot: args.policySnapshot,
57
- engineCounters: args.engineCounters,
58
- status: "running",
59
- queue: [],
60
- outputsByNode: {} as Record<NodeId, NodeOutputs>,
61
- nodeSnapshotsByNodeId: {},
62
- connectionInvocations: [],
63
- };
64
- const stateJson = this.serializeStateBlob(state);
65
94
  await this.prisma.run.create({
66
95
  data: {
67
96
  runId: args.runId,
@@ -71,7 +100,13 @@ export class PrismaWorkflowRunRepository implements WorkflowRunRepository, Workf
71
100
  parentJson: args.parent ? JSON.stringify(args.parent) : null,
72
101
  executionOptionsJson: args.executionOptions ? JSON.stringify(args.executionOptions) : null,
73
102
  updatedAt: now,
74
- stateJson,
103
+ revision: 0,
104
+ outputsByNodeJson: JSON.stringify({}),
105
+ controlJson: args.control ? JSON.stringify(args.control) : null,
106
+ workflowSnapshotJson: args.workflowSnapshot ? JSON.stringify(args.workflowSnapshot) : null,
107
+ policySnapshotJson: args.policySnapshot ? JSON.stringify(args.policySnapshot) : null,
108
+ engineCountersJson: args.engineCounters ? JSON.stringify(args.engineCounters) : null,
109
+ mutableStateJson: args.mutableState ? JSON.stringify(args.mutableState) : null,
75
110
  },
76
111
  });
77
112
  }
@@ -80,31 +115,273 @@ export class PrismaWorkflowRunRepository implements WorkflowRunRepository, Workf
80
115
  const id = decodeURIComponent(runId) as RunId;
81
116
  const row = await this.prisma.run.findUnique({ where: { runId: id } });
82
117
  if (!row) return undefined;
83
- return this.rowToPersistedRunState(row);
118
+
119
+ const [schedulingState, instances] = await Promise.all([
120
+ this.loadSchedulingState(id),
121
+ this.prisma.executionInstance.findMany({
122
+ where: { runId: id },
123
+ orderBy: [{ slotNodeId: "asc" }, { runIndex: "asc" }],
124
+ }),
125
+ ]);
126
+
127
+ const { nodeSnapshotsByNodeId, connectionInvocations } = this.instancesToDomain(
128
+ row.runId as RunId,
129
+ row.workflowId as WorkflowId,
130
+ instances as ExecutionInstanceRow[],
131
+ );
132
+
133
+ const parent = this.parseJson<ParentExecutionRef>(row.parentJson);
134
+ const persistedOutputsByNode = this.parseJson<Record<NodeId, NodeOutputs>>(row.outputsByNodeJson) ?? {};
135
+ const merged: PersistedRunState = {
136
+ runId: row.runId as RunId,
137
+ workflowId: row.workflowId as WorkflowId,
138
+ startedAt: row.startedAt,
139
+ finishedAt: row.finishedAt ?? undefined,
140
+ revision: row.revision,
141
+ parent,
142
+ executionOptions: this.parseJson(row.executionOptionsJson),
143
+ control: this.parseJson(row.controlJson),
144
+ workflowSnapshot: this.parseJson(row.workflowSnapshotJson),
145
+ mutableState: this.parseJson(row.mutableStateJson),
146
+ policySnapshot: this.parseJson(row.policySnapshotJson),
147
+ engineCounters: this.parseJson(row.engineCountersJson),
148
+ status: row.status as PersistedRunState["status"],
149
+ pending: schedulingState?.pending,
150
+ queue: schedulingState?.queue ?? [],
151
+ outputsByNode: this.mergePersistedOutputsByNode({
152
+ persistedOutputsByNode,
153
+ nodeSnapshotsByNodeId,
154
+ }),
155
+ nodeSnapshotsByNodeId,
156
+ connectionInvocations: connectionInvocations.length > 0 ? connectionInvocations : undefined,
157
+ };
158
+ return this.applyParentToSnapshots(merged);
159
+ }
160
+
161
+ async loadSchedulingState(runId: RunId): Promise<PersistedRunSchedulingState | undefined> {
162
+ const id = decodeURIComponent(runId) as RunId;
163
+ const row = await this.prisma.run.findUnique({
164
+ where: { runId: id },
165
+ select: { runId: true },
166
+ });
167
+ if (!row) {
168
+ return undefined;
169
+ }
170
+ const workItems = (await this.prisma.runWorkItem.findMany({
171
+ where: { runId: id },
172
+ orderBy: [{ status: "desc" }, { enqueuedAt: "asc" }, { workItemId: "asc" }],
173
+ })) as RunWorkItemRecord[];
174
+ const queue: RunQueueEntry[] = [];
175
+ let pending: PendingNodeExecution | undefined;
176
+ for (const workItem of workItems) {
177
+ if (workItem.status === "claimed") {
178
+ pending = this.toPendingNodeExecution(workItem);
179
+ continue;
180
+ }
181
+ if (workItem.status === "queued") {
182
+ queue.push(this.toQueueEntry(workItem));
183
+ }
184
+ }
185
+ return { pending, queue };
186
+ }
187
+
188
+ async loadRunDetail(runId: string): Promise<WorkflowRunDetailDto | undefined> {
189
+ const id = decodeURIComponent(runId) as RunId;
190
+ const [row, projection, instances] = await Promise.all([
191
+ this.prisma.run.findUnique({ where: { runId: id } }),
192
+ this.prisma.runSlotProjection.findUnique({ where: { runId: id } }),
193
+ this.prisma.executionInstance.findMany({
194
+ where: { runId: id },
195
+ orderBy: [{ updatedAt: "asc" }, { runIndex: "asc" }],
196
+ }),
197
+ ]);
198
+ if (!row) {
199
+ return undefined;
200
+ }
201
+ const slotStates = this.toSlotStateDtos(projection as RunSlotProjectionRow | null);
202
+ const executionInstances = (instances as ExecutionInstanceRow[]).map((instance) =>
203
+ this.toExecutionInstanceDto(instance),
204
+ );
205
+ return {
206
+ runId: row.runId as RunId,
207
+ workflowId: row.workflowId as WorkflowId,
208
+ startedAt: row.startedAt,
209
+ finishedAt: row.finishedAt ?? undefined,
210
+ status: row.status as WorkflowRunDetailDto["status"],
211
+ workflowSnapshot: this.parseJson(row.workflowSnapshotJson),
212
+ mutableState: this.parseJson(row.mutableStateJson) as WorkflowRunDetailDto["mutableState"],
213
+ slotStates,
214
+ executionInstances,
215
+ };
216
+ }
217
+
218
+ async listBinaryStorageKeys(runId: RunId): Promise<ReadonlyArray<string>> {
219
+ const id = decodeURIComponent(runId) as RunId;
220
+ const row = await this.prisma.run.findUnique({
221
+ where: { runId: id },
222
+ select: { outputsByNodeJson: true, mutableStateJson: true },
223
+ });
224
+ if (!row) {
225
+ return [];
226
+ }
227
+ const instances = (await this.prisma.executionInstance.findMany({
228
+ where: { runId: id },
229
+ select: { inputJson: true, outputJson: true },
230
+ })) as Array<Pick<ExecutionInstanceRow, "inputJson" | "outputJson">>;
231
+ const keys = new Set<string>();
232
+ this.collectBinaryKeysFromJsonText(row.outputsByNodeJson, keys);
233
+ this.collectBinaryKeysFromJsonText(row.mutableStateJson, keys);
234
+ for (const instance of instances) {
235
+ this.collectBinaryKeysFromJsonText(instance.inputJson, keys);
236
+ this.collectBinaryKeysFromJsonText(instance.outputJson, keys);
237
+ }
238
+ return [...keys].sort((left, right) => left.localeCompare(right));
239
+ }
240
+
241
+ private applyParentToSnapshots(state: PersistedRunState): PersistedRunState {
242
+ if (!state.parent) {
243
+ return state;
244
+ }
245
+ const next: Record<NodeId, NodeExecutionSnapshot> = {};
246
+ for (const [id, snap] of Object.entries(state.nodeSnapshotsByNodeId ?? {})) {
247
+ next[id] = { ...snap, parent: state.parent };
248
+ }
249
+ return { ...state, nodeSnapshotsByNodeId: next };
84
250
  }
85
251
 
86
252
  async save(state: PersistedRunState): Promise<void> {
253
+ let candidate = state;
254
+ for (let attempt = 0; attempt < 3; attempt += 1) {
255
+ try {
256
+ await this.saveOnce(candidate);
257
+ return;
258
+ } catch (error) {
259
+ if (!this.isConcurrentRunUpdateError(error) || attempt === 2) {
260
+ throw error;
261
+ }
262
+ const latest = await this.load(candidate.runId);
263
+ if (!latest) {
264
+ throw error;
265
+ }
266
+ candidate = this.mergeConcurrentState(latest, candidate);
267
+ }
268
+ }
269
+ }
270
+
271
+ private async saveOnce(state: PersistedRunState): Promise<void> {
87
272
  const now = new Date().toISOString();
88
- const stateJson = this.serializeStateBlob(state);
89
- await this.prisma.run.upsert({
90
- where: { runId: state.runId },
91
- create: {
92
- runId: state.runId,
93
- workflowId: state.workflowId,
94
- startedAt: state.startedAt,
95
- status: state.status,
96
- parentJson: state.parent ? JSON.stringify(state.parent) : null,
97
- executionOptionsJson: state.executionOptions ? JSON.stringify(state.executionOptions) : null,
98
- updatedAt: now,
99
- stateJson,
100
- },
101
- update: {
102
- status: state.status,
103
- parentJson: state.parent ? JSON.stringify(state.parent) : null,
104
- executionOptionsJson: state.executionOptions ? JSON.stringify(state.executionOptions) : null,
105
- updatedAt: now,
106
- stateJson,
107
- },
273
+ const nextRevision = (state.revision ?? 0) + 1;
274
+ const workItems = this.buildWorkItems(state, now);
275
+ const instances = this.buildExecutionInstances(state);
276
+ const projectionJson = this.buildProjectionSlotStatesJson(state);
277
+
278
+ await this.prisma.$transaction(async (tx) => {
279
+ await tx.runWorkItem.deleteMany({ where: { runId: state.runId } });
280
+ if (workItems.length > 0) {
281
+ await tx.runWorkItem.createMany({ data: workItems });
282
+ }
283
+ const existingInstances = await tx.executionInstance.findMany({
284
+ where: { runId: state.runId },
285
+ select: { instanceId: true, slotNodeId: true, runIndex: true },
286
+ });
287
+ const existingById = new Map(existingInstances.map((row) => [row.instanceId, row]));
288
+ const maxRunIndexBySlot = new Map<string, number>();
289
+ for (const row of existingInstances) {
290
+ maxRunIndexBySlot.set(row.slotNodeId, Math.max(maxRunIndexBySlot.get(row.slotNodeId) ?? 0, row.runIndex));
291
+ }
292
+ for (const instance of instances) {
293
+ const existing = existingById.get(instance.instanceId);
294
+ const runIndex = existing?.runIndex ?? this.nextRunIndexForSlot(maxRunIndexBySlot, instance.slotNodeId);
295
+ if (existing) {
296
+ await tx.executionInstance.update({
297
+ where: { instanceId: instance.instanceId },
298
+ data: {
299
+ workflowId: instance.workflowId,
300
+ slotNodeId: instance.slotNodeId,
301
+ workflowNodeId: instance.workflowNodeId,
302
+ kind: instance.kind,
303
+ connectionKind: instance.connectionKind,
304
+ activationId: instance.activationId,
305
+ batchId: instance.batchId,
306
+ parentInstanceId: instance.parentInstanceId,
307
+ parentRunId: instance.parentRunId,
308
+ workerClaimToken: instance.workerClaimToken,
309
+ status: instance.status,
310
+ queuedAt: instance.queuedAt,
311
+ startedAt: instance.startedAt,
312
+ finishedAt: instance.finishedAt,
313
+ updatedAt: instance.updatedAt,
314
+ itemCount: instance.itemCount,
315
+ inputJson: instance.inputJson,
316
+ outputJson: instance.outputJson,
317
+ errorJson: instance.errorJson,
318
+ inputItemIndicesJson: instance.inputItemIndicesJson,
319
+ outputItemCount: instance.outputItemCount,
320
+ successfulItemCount: instance.successfulItemCount,
321
+ failedItemCount: instance.failedItemCount,
322
+ inputStorageKind: instance.inputStorageKind,
323
+ outputStorageKind: instance.outputStorageKind,
324
+ inputBytes: instance.inputBytes,
325
+ outputBytes: instance.outputBytes,
326
+ inputPreviewJson: instance.inputPreviewJson,
327
+ outputPreviewJson: instance.outputPreviewJson,
328
+ inputPayloadRef: instance.inputPayloadRef,
329
+ outputPayloadRef: instance.outputPayloadRef,
330
+ inputTruncated: instance.inputTruncated,
331
+ outputTruncated: instance.outputTruncated,
332
+ usedPinnedOutput: instance.usedPinnedOutput,
333
+ },
334
+ });
335
+ continue;
336
+ }
337
+ await tx.executionInstance.create({
338
+ data: {
339
+ ...instance,
340
+ runIndex,
341
+ },
342
+ });
343
+ }
344
+ await tx.runSlotProjection.upsert({
345
+ where: { runId: state.runId },
346
+ create: {
347
+ runId: state.runId,
348
+ workflowId: state.workflowId,
349
+ revision: nextRevision,
350
+ updatedAt: now,
351
+ slotStatesJson: projectionJson,
352
+ },
353
+ update: {
354
+ workflowId: state.workflowId,
355
+ revision: nextRevision,
356
+ updatedAt: now,
357
+ slotStatesJson: projectionJson,
358
+ },
359
+ });
360
+ const updated = await tx.run.updateMany({
361
+ where: {
362
+ runId: state.runId,
363
+ revision: state.revision ?? 0,
364
+ },
365
+ data: {
366
+ workflowId: state.workflowId,
367
+ startedAt: state.startedAt,
368
+ status: state.status,
369
+ finishedAt: state.finishedAt ?? null,
370
+ updatedAt: now,
371
+ revision: nextRevision,
372
+ parentJson: state.parent ? JSON.stringify(state.parent) : null,
373
+ executionOptionsJson: state.executionOptions ? JSON.stringify(state.executionOptions) : null,
374
+ controlJson: state.control ? JSON.stringify(state.control) : null,
375
+ workflowSnapshotJson: state.workflowSnapshot ? JSON.stringify(state.workflowSnapshot) : null,
376
+ policySnapshotJson: state.policySnapshot ? JSON.stringify(state.policySnapshot) : null,
377
+ engineCountersJson: state.engineCounters ? JSON.stringify(state.engineCounters) : null,
378
+ mutableStateJson: state.mutableState ? JSON.stringify(state.mutableState) : null,
379
+ outputsByNodeJson: JSON.stringify(this.buildPersistedOutputsByNode(state)),
380
+ },
381
+ });
382
+ if (updated.count !== 1) {
383
+ throw new Error(`Concurrent run update detected for run ${state.runId}.`);
384
+ }
108
385
  });
109
386
  }
110
387
 
@@ -131,7 +408,10 @@ export class PrismaWorkflowRunRepository implements WorkflowRunRepository, Workf
131
408
  const rows = await this.prisma.run.findMany({
132
409
  where: {
133
410
  status: { in: ["completed", "failed"] },
134
- updatedAt: { lt: args.beforeIso },
411
+ OR: [
412
+ { AND: [{ finishedAt: { not: null } }, { finishedAt: { lt: args.beforeIso } }] },
413
+ { AND: [{ finishedAt: null }, { updatedAt: { lt: args.beforeIso } }] },
414
+ ],
135
415
  },
136
416
  orderBy: { updatedAt: "asc" },
137
417
  take: limit,
@@ -139,81 +419,230 @@ export class PrismaWorkflowRunRepository implements WorkflowRunRepository, Workf
139
419
  return rows.map((r) => this.rowToPruneCandidate(r));
140
420
  }
141
421
 
142
- private serializeStateBlob(state: PersistedRunState): string {
143
- const blob: StateJsonBlob = {
144
- control: state.control,
145
- workflowSnapshot: state.workflowSnapshot,
146
- mutableState: state.mutableState,
147
- policySnapshot: state.policySnapshot,
148
- engineCounters: state.engineCounters,
149
- pending: state.pending,
150
- queue: state.queue,
151
- outputsByNode: state.outputsByNode,
152
- nodeSnapshotsByNodeId: state.nodeSnapshotsByNodeId,
153
- connectionInvocations: state.connectionInvocations,
422
+ private instancesToDomain(
423
+ runId: RunId,
424
+ workflowId: WorkflowId,
425
+ instances: ExecutionInstanceRow[],
426
+ ): {
427
+ nodeSnapshotsByNodeId: Record<NodeId, NodeExecutionSnapshot>;
428
+ connectionInvocations: ReadonlyArray<ConnectionInvocationRecord>;
429
+ } {
430
+ const nodeSnapshotsByNodeId: Record<NodeId, NodeExecutionSnapshot> = {};
431
+ const connectionInvocations: ConnectionInvocationRecord[] = [];
432
+ const workflowRows = instances.filter((i) => i.kind === "workflowNodeActivation");
433
+ const byNode = new Map<NodeId, ExecutionInstanceRow>();
434
+ for (const row of workflowRows) {
435
+ const prev = byNode.get(row.slotNodeId);
436
+ if (!prev || row.updatedAt > prev.updatedAt) {
437
+ byNode.set(row.slotNodeId, row);
438
+ }
439
+ }
440
+ for (const [nodeId, row] of byNode.entries()) {
441
+ nodeSnapshotsByNodeId[nodeId] = this.rowToNodeSnapshot(runId, workflowId, nodeId, row);
442
+ }
443
+ const connRows = instances.filter((i) => i.kind === "connectionInvocation").sort((a, b) => a.runIndex - b.runIndex);
444
+ for (const row of connRows) {
445
+ connectionInvocations.push(this.rowToConnectionInvocation(runId, workflowId, row));
446
+ }
447
+ return { nodeSnapshotsByNodeId, connectionInvocations };
448
+ }
449
+
450
+ private rowToNodeSnapshot(
451
+ runId: RunId,
452
+ workflowId: WorkflowId,
453
+ nodeId: NodeId,
454
+ row: ExecutionInstanceRow,
455
+ ): NodeExecutionSnapshot {
456
+ const inputsByPort = row.inputJson
457
+ ? (JSON.parse(row.inputJson) as NodeExecutionSnapshot["inputsByPort"])
458
+ : undefined;
459
+ const outputs = row.outputJson ? (JSON.parse(row.outputJson) as NodeOutputs) : undefined;
460
+ const error = row.errorJson
461
+ ? (JSON.parse(row.errorJson) as NonNullable<NodeExecutionSnapshot["error"]>)
462
+ : undefined;
463
+ return {
464
+ runId,
465
+ workflowId,
466
+ nodeId,
467
+ activationId: row.activationId ?? undefined,
468
+ status: row.status as NodeExecutionSnapshot["status"],
469
+ usedPinnedOutput: row.usedPinnedOutput ?? undefined,
470
+ queuedAt: row.queuedAt ?? undefined,
471
+ startedAt: row.startedAt ?? undefined,
472
+ finishedAt: row.finishedAt ?? undefined,
473
+ updatedAt: row.updatedAt,
474
+ inputsByPort,
475
+ outputs,
476
+ error,
154
477
  };
155
- return JSON.stringify(blob);
156
478
  }
157
479
 
158
- private parseStateBlob(json: string): StateJsonBlob {
159
- const parsed = JSON.parse(json) as StateJsonBlob;
480
+ private rowToConnectionInvocation(
481
+ runId: RunId,
482
+ workflowId: WorkflowId,
483
+ row: ExecutionInstanceRow,
484
+ ): ConnectionInvocationRecord {
485
+ const err = row.errorJson
486
+ ? (JSON.parse(row.errorJson) as NonNullable<ConnectionInvocationRecord["error"]>)
487
+ : undefined;
160
488
  return {
161
- control: parsed.control,
162
- workflowSnapshot: parsed.workflowSnapshot,
163
- mutableState: parsed.mutableState,
164
- policySnapshot: parsed.policySnapshot,
165
- engineCounters: parsed.engineCounters,
166
- pending: parsed.pending,
167
- queue: parsed.queue ?? [],
168
- outputsByNode: (parsed.outputsByNode ?? {}) as Record<NodeId, NodeOutputs>,
169
- nodeSnapshotsByNodeId: parsed.nodeSnapshotsByNodeId ?? {},
170
- connectionInvocations: parsed.connectionInvocations,
489
+ invocationId: row.instanceId,
490
+ runId,
491
+ workflowId,
492
+ connectionNodeId: row.slotNodeId,
493
+ parentAgentNodeId: row.workflowNodeId,
494
+ parentAgentActivationId: row.activationId ?? `synthetic_${row.workflowNodeId}`,
495
+ status: row.status as ConnectionInvocationRecord["status"],
496
+ managedInput: row.inputJson ? JSON.parse(row.inputJson) : undefined,
497
+ managedOutput: row.outputJson ? JSON.parse(row.outputJson) : undefined,
498
+ error: err,
499
+ queuedAt: row.queuedAt ?? undefined,
500
+ startedAt: row.startedAt ?? undefined,
501
+ finishedAt: row.finishedAt ?? undefined,
502
+ updatedAt: row.updatedAt,
171
503
  };
172
504
  }
173
505
 
174
- private rowToPersistedRunState(row: {
175
- runId: string;
176
- workflowId: string;
177
- startedAt: string;
178
- status: string;
179
- parentJson: string | null;
180
- executionOptionsJson: string | null;
181
- stateJson: string;
182
- }): PersistedRunState {
183
- const blob = this.parseStateBlob(row.stateJson);
506
+ private toExecutionInstanceDto(row: ExecutionInstanceRow): ExecutionInstanceDto {
184
507
  return {
185
- runId: row.runId as RunId,
186
- workflowId: row.workflowId as WorkflowId,
187
- startedAt: row.startedAt,
188
- status: row.status as PersistedRunState["status"],
189
- parent: row.parentJson ? (JSON.parse(row.parentJson) as ParentExecutionRef) : undefined,
190
- executionOptions: row.executionOptionsJson
191
- ? (JSON.parse(row.executionOptionsJson) as PersistedRunState["executionOptions"])
192
- : undefined,
193
- control: blob.control,
194
- workflowSnapshot: blob.workflowSnapshot,
195
- mutableState: blob.mutableState,
196
- policySnapshot: blob.policySnapshot,
197
- engineCounters: blob.engineCounters,
198
- pending: blob.pending,
199
- queue: blob.queue,
200
- outputsByNode: blob.outputsByNode,
201
- nodeSnapshotsByNodeId: blob.nodeSnapshotsByNodeId,
202
- connectionInvocations: blob.connectionInvocations,
508
+ instanceId: row.instanceId,
509
+ slotNodeId: row.slotNodeId as NodeId,
510
+ workflowNodeId: row.workflowNodeId as NodeId,
511
+ parentInstanceId: row.parentInstanceId ?? undefined,
512
+ kind: row.kind as ExecutionInstanceDto["kind"],
513
+ connectionKind: row.connectionKind as ExecutionInstanceDto["connectionKind"],
514
+ runIndex: row.runIndex,
515
+ batchId: row.batchId,
516
+ activationId: row.activationId ?? undefined,
517
+ status: row.status as ExecutionInstanceDto["status"],
518
+ queuedAt: row.queuedAt ?? undefined,
519
+ startedAt: row.startedAt ?? undefined,
520
+ finishedAt: row.finishedAt ?? undefined,
521
+ itemCount: row.itemCount,
522
+ inputJson: row.inputJson ? (JSON.parse(row.inputJson) as ExecutionInstanceDto["inputJson"]) : undefined,
523
+ outputJson: row.outputJson ? (JSON.parse(row.outputJson) as ExecutionInstanceDto["outputJson"]) : undefined,
524
+ error: row.errorJson ? (JSON.parse(row.errorJson) as ExecutionInstanceDto["error"]) : undefined,
203
525
  };
204
526
  }
205
527
 
528
+ private buildWorkItems(state: PersistedRunState, nowIso: string): Prisma.RunWorkItemCreateManyInput[] {
529
+ const rows: Prisma.RunWorkItemCreateManyInput[] = [];
530
+ for (const [index, entry] of (state.queue ?? []).entries()) {
531
+ const inputsByPort = this.inputsByPortFromQueueEntry(entry);
532
+ rows.push({
533
+ workItemId: `${state.runId}:queued:${index}:${entry.nodeId}:${entry.batchId ?? "batch_1"}`,
534
+ runId: state.runId,
535
+ workflowId: state.workflowId,
536
+ status: "queued",
537
+ targetNodeId: entry.nodeId,
538
+ batchId: entry.batchId ?? "batch_1",
539
+ availableAt: nowIso,
540
+ enqueuedAt: nowIso,
541
+ itemsIn: this.countItemsByPort(inputsByPort),
542
+ inputsByPortJson: JSON.stringify(inputsByPort),
543
+ });
544
+ }
545
+ if (state.pending) {
546
+ rows.push({
547
+ workItemId: state.pending.activationId,
548
+ runId: state.runId,
549
+ workflowId: state.workflowId,
550
+ status: "claimed",
551
+ targetNodeId: state.pending.nodeId,
552
+ batchId: state.pending.batchId ?? "batch_1",
553
+ queueName: state.pending.queue,
554
+ claimToken: state.pending.activationId,
555
+ claimedAt: nowIso,
556
+ availableAt: state.pending.enqueuedAt,
557
+ enqueuedAt: state.pending.enqueuedAt,
558
+ itemsIn: state.pending.itemsIn,
559
+ inputsByPortJson: JSON.stringify(state.pending.inputsByPort),
560
+ });
561
+ }
562
+ return rows;
563
+ }
564
+
565
+ private buildExecutionInstances(state: PersistedRunState): Prisma.ExecutionInstanceCreateManyInput[] {
566
+ const rows: Prisma.ExecutionInstanceCreateManyInput[] = [];
567
+ for (const [nodeId, snap] of Object.entries(state.nodeSnapshotsByNodeId ?? {})) {
568
+ const instanceId = `${state.runId}:node:${nodeId}:${snap.activationId ?? "na"}`;
569
+ const itemCount = this.countItemsByPort(snap.inputsByPort) || this.countItemsInOutputs(snap.outputs);
570
+ rows.push({
571
+ instanceId,
572
+ runId: state.runId,
573
+ workflowId: state.workflowId,
574
+ slotNodeId: nodeId,
575
+ workflowNodeId: nodeId,
576
+ kind: "workflowNodeActivation",
577
+ connectionKind: null,
578
+ activationId: snap.activationId ?? null,
579
+ batchId: state.pending?.batchId ?? "batch_1",
580
+ runIndex: 1,
581
+ status: snap.status,
582
+ queuedAt: snap.queuedAt ?? null,
583
+ startedAt: snap.startedAt ?? null,
584
+ finishedAt: snap.finishedAt ?? null,
585
+ updatedAt: snap.updatedAt,
586
+ itemCount,
587
+ inputJson: snap.inputsByPort ? JSON.stringify(snap.inputsByPort) : null,
588
+ outputJson: snap.outputs ? JSON.stringify(snap.outputs) : null,
589
+ errorJson: snap.error ? JSON.stringify(snap.error) : null,
590
+ inputItemIndicesJson: null,
591
+ outputItemCount: snap.outputs ? this.countItemsInOutputs(snap.outputs) : null,
592
+ successfulItemCount: null,
593
+ failedItemCount: snap.status === "failed" ? itemCount : null,
594
+ inputStorageKind: "inline",
595
+ outputStorageKind: "inline",
596
+ usedPinnedOutput: snap.usedPinnedOutput ?? null,
597
+ });
598
+ }
599
+ let cIdx = 0;
600
+ for (const inv of state.connectionInvocations ?? []) {
601
+ rows.push({
602
+ instanceId: inv.invocationId,
603
+ runId: state.runId,
604
+ workflowId: state.workflowId,
605
+ slotNodeId: inv.connectionNodeId,
606
+ workflowNodeId: inv.parentAgentNodeId,
607
+ kind: "connectionInvocation",
608
+ connectionKind: "languageModel",
609
+ activationId: inv.parentAgentActivationId,
610
+ batchId: state.pending?.batchId ?? "batch_1",
611
+ runIndex: cIdx,
612
+ status: inv.status,
613
+ queuedAt: inv.queuedAt ?? null,
614
+ startedAt: inv.startedAt ?? null,
615
+ finishedAt: inv.finishedAt ?? null,
616
+ updatedAt: inv.updatedAt,
617
+ itemCount: 0,
618
+ inputJson: inv.managedInput !== undefined ? JSON.stringify(inv.managedInput) : null,
619
+ outputJson: inv.managedOutput !== undefined ? JSON.stringify(inv.managedOutput) : null,
620
+ errorJson: inv.error ? JSON.stringify(inv.error) : null,
621
+ inputItemIndicesJson: null,
622
+ outputItemCount: null,
623
+ successfulItemCount: null,
624
+ failedItemCount: null,
625
+ inputStorageKind: "inline",
626
+ outputStorageKind: "inline",
627
+ });
628
+ cIdx += 1;
629
+ }
630
+ return rows;
631
+ }
632
+
206
633
  private rowToPruneCandidate(row: {
207
634
  runId: string;
208
635
  workflowId: string;
209
636
  startedAt: string;
210
637
  updatedAt: string;
638
+ finishedAt: string | null;
211
639
  }): RunPruneCandidate {
640
+ const finishedAt = row.finishedAt ?? row.updatedAt;
212
641
  return {
213
642
  runId: row.runId as RunId,
214
643
  workflowId: row.workflowId as WorkflowId,
215
644
  startedAt: row.startedAt,
216
- finishedAt: row.updatedAt,
645
+ finishedAt,
217
646
  };
218
647
  }
219
648
 
@@ -225,9 +654,10 @@ export class PrismaWorkflowRunRepository implements WorkflowRunRepository, Workf
225
654
  parentJson: string | null;
226
655
  executionOptionsJson: string | null;
227
656
  updatedAt: string;
657
+ finishedAt: string | null;
228
658
  }): RunSummary {
229
659
  const status = row.status as RunSummary["status"];
230
- const finishedAt = status === "completed" || status === "failed" ? row.updatedAt : undefined;
660
+ const finishedAt = status === "completed" || status === "failed" ? (row.finishedAt ?? row.updatedAt) : undefined;
231
661
  return {
232
662
  runId: row.runId as RunId,
233
663
  workflowId: row.workflowId as WorkflowId,
@@ -240,4 +670,261 @@ export class PrismaWorkflowRunRepository implements WorkflowRunRepository, Workf
240
670
  : undefined,
241
671
  };
242
672
  }
673
+
674
+ private parseJson<T>(value: string | null): T | undefined {
675
+ if (!value) {
676
+ return undefined;
677
+ }
678
+ return JSON.parse(value) as T;
679
+ }
680
+
681
+ private toPendingNodeExecution(row: RunWorkItemRecord): PendingNodeExecution {
682
+ return {
683
+ runId: row.runId as RunId,
684
+ activationId: (row.claimToken ?? row.workItemId) as PendingNodeExecution["activationId"],
685
+ workflowId: row.workflowId as WorkflowId,
686
+ nodeId: row.targetNodeId as NodeId,
687
+ itemsIn: row.itemsIn,
688
+ inputsByPort: JSON.parse(row.inputsByPortJson) as NodeInputsByPort,
689
+ receiptId: row.claimToken ?? row.workItemId,
690
+ queue: row.queueName ?? undefined,
691
+ batchId: row.batchId,
692
+ enqueuedAt: row.enqueuedAt,
693
+ };
694
+ }
695
+
696
+ private toQueueEntry(row: RunWorkItemRecord): RunQueueEntry {
697
+ const inputsByPort = JSON.parse(row.inputsByPortJson) as NodeInputsByPort;
698
+ const portEntries = Object.entries(inputsByPort);
699
+ if (portEntries.length <= 1) {
700
+ const [portKey, items] = portEntries[0] ?? ["in", []];
701
+ return {
702
+ nodeId: row.targetNodeId as NodeId,
703
+ input: items,
704
+ toInput: portKey === "in" ? undefined : portKey,
705
+ batchId: row.batchId,
706
+ };
707
+ }
708
+ return {
709
+ nodeId: row.targetNodeId as NodeId,
710
+ input: [],
711
+ batchId: row.batchId,
712
+ collect: {
713
+ expectedInputs: portEntries.map(([portKey]) => portKey),
714
+ received: inputsByPort,
715
+ },
716
+ };
717
+ }
718
+
719
+ private inputsByPortFromQueueEntry(entry: RunQueueEntry): NodeInputsByPort {
720
+ if (entry.collect) {
721
+ return entry.collect.received;
722
+ }
723
+ return {
724
+ [(entry.toInput ?? "in") as string]: entry.input,
725
+ };
726
+ }
727
+
728
+ private countItemsByPort(inputsByPort: NodeInputsByPort | undefined): number {
729
+ let count = 0;
730
+ for (const items of Object.values(inputsByPort ?? {})) {
731
+ count += items.length;
732
+ }
733
+ return count;
734
+ }
735
+
736
+ private countItemsInOutputs(outputs: NodeOutputs | undefined): number {
737
+ let count = 0;
738
+ for (const items of Object.values(outputs ?? {})) {
739
+ count += items?.length ?? 0;
740
+ }
741
+ return count;
742
+ }
743
+
744
+ private buildProjectionSlotStatesJson(state: PersistedRunState): string {
745
+ const slotStatesByNodeId: Record<
746
+ string,
747
+ {
748
+ latestInstanceId?: string;
749
+ latestTerminalInstanceId?: string;
750
+ latestRunningInstanceId?: string;
751
+ latestStatus?: string;
752
+ invocationCount: number;
753
+ runCount: number;
754
+ }
755
+ > = {};
756
+ for (const [nodeId, snapshot] of Object.entries(state.nodeSnapshotsByNodeId ?? {})) {
757
+ const latestInstanceId = `${state.runId}:node:${nodeId}:${snapshot.activationId ?? "na"}`;
758
+ slotStatesByNodeId[nodeId] = {
759
+ latestInstanceId,
760
+ latestTerminalInstanceId:
761
+ snapshot.status === "completed" || snapshot.status === "failed" ? latestInstanceId : undefined,
762
+ latestRunningInstanceId:
763
+ snapshot.status === "queued" || snapshot.status === "running" ? latestInstanceId : undefined,
764
+ latestStatus: snapshot.status,
765
+ invocationCount: 0,
766
+ runCount: snapshot.status === "completed" || snapshot.status === "failed" ? 1 : 0,
767
+ };
768
+ }
769
+ for (const invocation of state.connectionInvocations ?? []) {
770
+ const existing = slotStatesByNodeId[invocation.connectionNodeId] ?? {
771
+ invocationCount: 0,
772
+ runCount: 0,
773
+ };
774
+ existing.invocationCount += 1;
775
+ slotStatesByNodeId[invocation.connectionNodeId] = existing;
776
+ }
777
+ return JSON.stringify({ slotStatesByNodeId });
778
+ }
779
+
780
+ private toSlotStateDtos(projection: RunSlotProjectionRow | null): ReadonlyArray<SlotExecutionStateDto> {
781
+ const state = this.parseJson<{ slotStatesByNodeId?: Record<string, SlotExecutionStateDto> }>(
782
+ projection?.slotStatesJson ?? null,
783
+ );
784
+ return Object.entries(state?.slotStatesByNodeId ?? {}).map(([slotNodeId, slotState]) => ({
785
+ slotNodeId: slotNodeId as NodeId,
786
+ latestInstanceId: slotState.latestInstanceId,
787
+ latestTerminalInstanceId: slotState.latestTerminalInstanceId,
788
+ latestRunningInstanceId: slotState.latestRunningInstanceId,
789
+ status:
790
+ slotState.status ??
791
+ (slotState as SlotExecutionStateDto & { latestStatus?: SlotExecutionStateDto["status"] }).latestStatus,
792
+ invocationCount: slotState.invocationCount,
793
+ runCount: slotState.runCount,
794
+ }));
795
+ }
796
+
797
+ private buildPersistedOutputsByNode(state: PersistedRunState): Record<NodeId, NodeOutputs> {
798
+ const persistedOutputsByNode: Record<NodeId, NodeOutputs> = {};
799
+ for (const [nodeId, outputs] of Object.entries(state.outputsByNode ?? {})) {
800
+ if (state.nodeSnapshotsByNodeId[nodeId]) {
801
+ continue;
802
+ }
803
+ persistedOutputsByNode[nodeId as NodeId] = outputs;
804
+ }
805
+ return persistedOutputsByNode;
806
+ }
807
+
808
+ private mergePersistedOutputsByNode(
809
+ args: Readonly<{
810
+ persistedOutputsByNode: Record<NodeId, NodeOutputs>;
811
+ nodeSnapshotsByNodeId: Record<NodeId, NodeExecutionSnapshot>;
812
+ }>,
813
+ ): Record<NodeId, NodeOutputs> {
814
+ const mergedOutputsByNode: Record<NodeId, NodeOutputs> = {
815
+ ...args.persistedOutputsByNode,
816
+ };
817
+ for (const [nodeId, snapshot] of Object.entries(args.nodeSnapshotsByNodeId)) {
818
+ if (snapshot.outputs) {
819
+ mergedOutputsByNode[nodeId as NodeId] = snapshot.outputs;
820
+ }
821
+ }
822
+ return mergedOutputsByNode;
823
+ }
824
+
825
+ private nextRunIndexForSlot(maxRunIndexBySlot: Map<string, number>, slotNodeId: string): number {
826
+ const next = (maxRunIndexBySlot.get(slotNodeId) ?? 0) + 1;
827
+ maxRunIndexBySlot.set(slotNodeId, next);
828
+ return next;
829
+ }
830
+
831
+ private mergeConcurrentState(latest: PersistedRunState, desired: PersistedRunState): PersistedRunState {
832
+ return {
833
+ ...latest,
834
+ ...desired,
835
+ revision: latest.revision,
836
+ parent: desired.parent ?? latest.parent,
837
+ executionOptions: desired.executionOptions ?? latest.executionOptions,
838
+ control: desired.control ?? latest.control,
839
+ workflowSnapshot: desired.workflowSnapshot ?? latest.workflowSnapshot,
840
+ mutableState: desired.mutableState ?? latest.mutableState,
841
+ policySnapshot: desired.policySnapshot ?? latest.policySnapshot,
842
+ engineCounters: desired.engineCounters ?? latest.engineCounters,
843
+ pending: desired.pending,
844
+ queue: desired.queue,
845
+ outputsByNode: {
846
+ ...(latest.outputsByNode ?? {}),
847
+ ...(desired.outputsByNode ?? {}),
848
+ },
849
+ nodeSnapshotsByNodeId: this.mergeNodeSnapshots(latest.nodeSnapshotsByNodeId, desired.nodeSnapshotsByNodeId),
850
+ connectionInvocations: this.mergeConnectionInvocations(
851
+ latest.connectionInvocations,
852
+ desired.connectionInvocations,
853
+ ),
854
+ };
855
+ }
856
+
857
+ private mergeNodeSnapshots(
858
+ latest: PersistedRunState["nodeSnapshotsByNodeId"],
859
+ desired: PersistedRunState["nodeSnapshotsByNodeId"],
860
+ ): PersistedRunState["nodeSnapshotsByNodeId"] {
861
+ const merged: PersistedRunState["nodeSnapshotsByNodeId"] = {
862
+ ...(latest ?? {}),
863
+ };
864
+ for (const [nodeId, snapshot] of Object.entries(desired ?? {})) {
865
+ const current = merged[nodeId as NodeId];
866
+ if (!current || (snapshot.updatedAt ?? "") >= (current.updatedAt ?? "")) {
867
+ merged[nodeId as NodeId] = snapshot;
868
+ }
869
+ }
870
+ return merged;
871
+ }
872
+
873
+ private mergeConnectionInvocations(
874
+ latest: PersistedRunState["connectionInvocations"],
875
+ desired: PersistedRunState["connectionInvocations"],
876
+ ): PersistedRunState["connectionInvocations"] {
877
+ const byId = new Map<string, NonNullable<PersistedRunState["connectionInvocations"]>[number]>();
878
+ for (const record of latest ?? []) {
879
+ byId.set(record.invocationId, record);
880
+ }
881
+ for (const record of desired ?? []) {
882
+ const current = byId.get(record.invocationId);
883
+ if (!current || record.updatedAt >= current.updatedAt) {
884
+ byId.set(record.invocationId, record);
885
+ }
886
+ }
887
+ return [...byId.values()].sort(
888
+ (left, right) =>
889
+ left.updatedAt.localeCompare(right.updatedAt) || left.invocationId.localeCompare(right.invocationId),
890
+ );
891
+ }
892
+
893
+ private isConcurrentRunUpdateError(error: unknown): boolean {
894
+ return error instanceof Error && error.message.includes("Concurrent run update detected");
895
+ }
896
+
897
+ private collectBinaryKeysFromJsonText(value: string | null, keys: Set<string>): void {
898
+ if (!value) {
899
+ return;
900
+ }
901
+ this.collectBinaryKeysFromValue(JSON.parse(value) as unknown, keys);
902
+ }
903
+
904
+ private collectBinaryKeysFromValue(value: unknown, keys: Set<string>): void {
905
+ if (Array.isArray(value)) {
906
+ for (const entry of value) {
907
+ this.collectBinaryKeysFromValue(entry, keys);
908
+ }
909
+ return;
910
+ }
911
+ if (!value || typeof value !== "object") {
912
+ return;
913
+ }
914
+ const record = value as Record<string, unknown>;
915
+ if (
916
+ typeof record.id === "string" &&
917
+ typeof record.storageKey === "string" &&
918
+ typeof record.mimeType === "string" &&
919
+ typeof record.size === "number"
920
+ ) {
921
+ if (record.storageKey.length > 0) {
922
+ keys.add(record.storageKey);
923
+ }
924
+ return;
925
+ }
926
+ for (const child of Object.values(record)) {
927
+ this.collectBinaryKeysFromValue(child, keys);
928
+ }
929
+ }
243
930
  }