@lota-sdk/core 0.1.9 → 0.1.11

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 (34) hide show
  1. package/infrastructure/schema/00_workstream.surql +1 -0
  2. package/infrastructure/schema/02_execution_plan.surql +202 -52
  3. package/package.json +4 -2
  4. package/src/bifrost/bifrost.ts +94 -25
  5. package/src/config/model-constants.ts +8 -6
  6. package/src/db/memory-store.ts +3 -71
  7. package/src/db/service.ts +42 -2
  8. package/src/db/tables.ts +9 -2
  9. package/src/embeddings/provider.ts +92 -21
  10. package/src/index.ts +6 -0
  11. package/src/redis/stream-context.ts +44 -0
  12. package/src/runtime/approval-continuation.ts +59 -0
  13. package/src/runtime/chat-request-routing.ts +5 -1
  14. package/src/runtime/execution-plan.ts +21 -14
  15. package/src/runtime/turn-lifecycle.ts +12 -4
  16. package/src/services/document-chunk.service.ts +2 -2
  17. package/src/services/execution-plan.service.ts +579 -786
  18. package/src/services/learned-skill.service.ts +2 -2
  19. package/src/services/plan-approval.service.ts +83 -0
  20. package/src/services/plan-artifact.service.ts +45 -0
  21. package/src/services/plan-builder.service.ts +61 -0
  22. package/src/services/plan-checkpoint.service.ts +53 -0
  23. package/src/services/plan-compiler.service.ts +81 -0
  24. package/src/services/plan-executor.service.ts +1623 -0
  25. package/src/services/plan-run.service.ts +422 -0
  26. package/src/services/plan-validator.service.ts +760 -0
  27. package/src/services/workstream-turn-preparation.ts +57 -15
  28. package/src/services/workstream-turn.ts +12 -0
  29. package/src/services/workstream.service.ts +26 -0
  30. package/src/services/workstream.types.ts +1 -0
  31. package/src/system-agents/title-generator.agent.ts +2 -2
  32. package/src/tools/execution-plan.tool.ts +20 -46
  33. package/src/tools/log-hello-world.tool.ts +17 -0
  34. package/src/workers/skill-extraction.runner.ts +2 -2
@@ -0,0 +1,1623 @@
1
+ import {
2
+ PlanEventSchema,
3
+ PlanNodeAttemptSchema,
4
+ PlanNodeRunSchema,
5
+ PlanRunSchema,
6
+ PlanValidationIssueSchema,
7
+ } from '@lota-sdk/shared/schemas/execution-plan'
8
+ import type {
9
+ PlanEventRecord,
10
+ PlanEventType,
11
+ PlanFailureAction,
12
+ PlanFailureClass,
13
+ PlanNodeRunRecord,
14
+ PlanNodeSpecRecord,
15
+ PlanRunRecord,
16
+ PlanSpecRecord,
17
+ PlanValidationIssueRecord,
18
+ SerializableExecutionPlan,
19
+ } from '@lota-sdk/shared/schemas/execution-plan'
20
+ import type { ExecutionPlanToolResultData, PlanNodeResultSubmission } from '@lota-sdk/shared/schemas/tools'
21
+ import { RecordId } from 'surrealdb'
22
+
23
+ import type { RecordIdInput } from '../db/record-id'
24
+ import { ensureRecordId, recordIdToString } from '../db/record-id'
25
+ import { databaseService } from '../db/service'
26
+ import type { DatabaseTransaction } from '../db/service'
27
+ import { TABLES } from '../db/tables'
28
+ import { planApprovalService } from './plan-approval.service'
29
+ import { planArtifactService } from './plan-artifact.service'
30
+ import { planCheckpointService } from './plan-checkpoint.service'
31
+ import { planRunService } from './plan-run.service'
32
+ import type { PlanValidationIssueInput } from './plan-validator.service'
33
+ import { planValidatorService } from './plan-validator.service'
34
+
35
+ const SUCCESSFUL_TERMINAL_NODE_STATUSES = new Set(['completed', 'partial', 'skipped'])
36
+ const HUMAN_NODE_TYPES = new Set(['human-input', 'human-approval', 'human-review-edit', 'human-decision'])
37
+ const STRUCTURAL_NODE_TYPES = new Set(['switch', 'join'])
38
+
39
+ function isRecord(value: unknown): value is Record<string, unknown> {
40
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
41
+ }
42
+
43
+ function readPathValue(source: unknown, path: string): unknown {
44
+ if (!path.trim()) return source
45
+
46
+ let current: unknown = source
47
+ for (const segment of path
48
+ .split('.')
49
+ .map((part) => part.trim())
50
+ .filter(Boolean)) {
51
+ if (!isRecord(current)) return undefined
52
+ current = current[segment]
53
+ }
54
+ return current
55
+ }
56
+
57
+ function setPathValue(target: Record<string, unknown>, path: string, value: unknown) {
58
+ const segments = path
59
+ .split('.')
60
+ .map((part) => part.trim())
61
+ .filter(Boolean)
62
+ if (segments.length === 0) return
63
+ const lastSegment = segments.at(-1)
64
+ if (!lastSegment) return
65
+
66
+ let current: Record<string, unknown> = target
67
+ for (const segment of segments.slice(0, -1)) {
68
+ const next = current[segment]
69
+ if (!isRecord(next)) {
70
+ current[segment] = {}
71
+ }
72
+ current = current[segment] as Record<string, unknown>
73
+ }
74
+ current[lastSegment] = value
75
+ }
76
+
77
+ function parseLiteralValue(raw: string): unknown {
78
+ const trimmed = raw.trim()
79
+ if (!trimmed.length) return undefined
80
+ if (trimmed === 'true') return true
81
+ if (trimmed === 'false') return false
82
+ if (trimmed === 'null') return null
83
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed)
84
+ if (
85
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
86
+ (trimmed.startsWith("'") && trimmed.endsWith("'")) ||
87
+ (trimmed.startsWith('`') && trimmed.endsWith('`'))
88
+ ) {
89
+ return trimmed.slice(1, -1)
90
+ }
91
+
92
+ try {
93
+ return JSON.parse(trimmed)
94
+ } catch {
95
+ return trimmed
96
+ }
97
+ }
98
+
99
+ function buildArtifactContext(
100
+ artifacts: Array<{ name: string; kind: string; pointer: string; schemaRef?: string; payload?: unknown }>,
101
+ ) {
102
+ return Object.fromEntries(
103
+ artifacts.map((artifact) => [
104
+ artifact.name,
105
+ { kind: artifact.kind, pointer: artifact.pointer, schemaRef: artifact.schemaRef, payload: artifact.payload },
106
+ ]),
107
+ )
108
+ }
109
+
110
+ function buildNodeContext(params: {
111
+ nodeRun: PlanNodeRunRecord | undefined
112
+ artifacts: Array<{ name: string; kind: string; pointer: string; schemaRef?: string; payload?: unknown }>
113
+ }) {
114
+ return {
115
+ input: params.nodeRun?.resolvedInput ?? {},
116
+ output: params.nodeRun?.latestStructuredOutput ?? {},
117
+ artifact: buildArtifactContext(params.artifacts),
118
+ }
119
+ }
120
+
121
+ function evaluateCondition(expression: string | undefined, context: Record<string, unknown>): boolean {
122
+ if (!expression?.trim()) return true
123
+ const normalized = expression.trim()
124
+ if (normalized === 'always') return true
125
+
126
+ const match = normalized.match(/^([a-zA-Z0-9_.]+)\s*(==|!=|>=|<=|>|<)\s*(.+)$/)
127
+ if (!match) {
128
+ return Boolean(readPathValue(context, normalized))
129
+ }
130
+
131
+ const [, leftPath, operator, rawRightValue] = match
132
+ const left = readPathValue(context, leftPath)
133
+ const right = parseLiteralValue(rawRightValue)
134
+
135
+ if (operator === '==') return Object.is(left, right)
136
+ if (operator === '!=') return !Object.is(left, right)
137
+ if (typeof left !== 'number' || typeof right !== 'number') return false
138
+ if (operator === '>=') return left >= right
139
+ if (operator === '<=') return left <= right
140
+ if (operator === '>') return left > right
141
+ return left < right
142
+ }
143
+
144
+ function isSuccessfulTerminalStatus(status: string): boolean {
145
+ return SUCCESSFUL_TERMINAL_NODE_STATUSES.has(status)
146
+ }
147
+
148
+ function isHumanNodeType(type: string): boolean {
149
+ return HUMAN_NODE_TYPES.has(type)
150
+ }
151
+
152
+ function isStructuralNodeType(type: string): boolean {
153
+ return STRUCTURAL_NODE_TYPES.has(type)
154
+ }
155
+
156
+ type PlanRunUpdate = Omit<
157
+ Partial<PlanRunRecord>,
158
+ 'currentNodeId' | 'waitingNodeId' | 'replacedRunId' | 'lastCheckpointId' | 'startedAt' | 'completedAt'
159
+ > & {
160
+ currentNodeId?: string | null
161
+ waitingNodeId?: string | null
162
+ replacedRunId?: RecordIdInput | null
163
+ lastCheckpointId?: RecordIdInput | null
164
+ startedAt?: Date | null
165
+ completedAt?: Date | null
166
+ }
167
+
168
+ type PlanNodeRunUpdate = Omit<
169
+ Partial<PlanNodeRunRecord>,
170
+ | 'blockedReason'
171
+ | 'failureClass'
172
+ | 'resolvedInput'
173
+ | 'latestStructuredOutput'
174
+ | 'latestNotes'
175
+ | 'latestAttemptId'
176
+ | 'readyAt'
177
+ | 'startedAt'
178
+ | 'completedAt'
179
+ > & {
180
+ blockedReason?: string | null
181
+ failureClass?: PlanFailureClass | null
182
+ resolvedInput?: Record<string, unknown> | null
183
+ latestStructuredOutput?: Record<string, unknown> | null
184
+ latestNotes?: string | null
185
+ latestAttemptId?: RecordIdInput | null
186
+ readyAt?: Date | null
187
+ startedAt?: Date | null
188
+ completedAt?: Date | null
189
+ }
190
+
191
+ function toRunData(run: PlanRunRecord, patch: PlanRunUpdate) {
192
+ return {
193
+ planSpecId: ensureRecordId(run.planSpecId, TABLES.PLAN_SPEC),
194
+ organizationId: ensureRecordId(run.organizationId, TABLES.ORGANIZATION),
195
+ workstreamId: ensureRecordId(run.workstreamId, TABLES.WORKSTREAM),
196
+ leadAgentId: patch.leadAgentId ?? run.leadAgentId,
197
+ status: patch.status ?? run.status,
198
+ ...(patch.currentNodeId === null
199
+ ? {}
200
+ : patch.currentNodeId !== undefined
201
+ ? { currentNodeId: patch.currentNodeId }
202
+ : run.currentNodeId
203
+ ? { currentNodeId: run.currentNodeId }
204
+ : {}),
205
+ ...(patch.waitingNodeId === null
206
+ ? {}
207
+ : patch.waitingNodeId !== undefined
208
+ ? { waitingNodeId: patch.waitingNodeId }
209
+ : run.waitingNodeId
210
+ ? { waitingNodeId: run.waitingNodeId }
211
+ : {}),
212
+ readyNodeIds: patch.readyNodeIds ? [...patch.readyNodeIds] : [...run.readyNodeIds],
213
+ failureCount: patch.failureCount ?? run.failureCount,
214
+ ...(patch.replacedRunId === null
215
+ ? {}
216
+ : patch.replacedRunId
217
+ ? { replacedRunId: ensureRecordId(patch.replacedRunId, TABLES.PLAN_RUN) }
218
+ : run.replacedRunId
219
+ ? { replacedRunId: ensureRecordId(run.replacedRunId, TABLES.PLAN_RUN) }
220
+ : {}),
221
+ ...(patch.lastCheckpointId === null
222
+ ? {}
223
+ : patch.lastCheckpointId
224
+ ? { lastCheckpointId: ensureRecordId(patch.lastCheckpointId, TABLES.PLAN_CHECKPOINT) }
225
+ : run.lastCheckpointId
226
+ ? { lastCheckpointId: ensureRecordId(run.lastCheckpointId, TABLES.PLAN_CHECKPOINT) }
227
+ : {}),
228
+ ...(patch.startedAt === null
229
+ ? {}
230
+ : patch.startedAt !== undefined
231
+ ? { startedAt: patch.startedAt }
232
+ : run.startedAt
233
+ ? { startedAt: run.startedAt }
234
+ : {}),
235
+ ...(patch.completedAt === null
236
+ ? {}
237
+ : patch.completedAt !== undefined
238
+ ? { completedAt: patch.completedAt }
239
+ : run.completedAt
240
+ ? { completedAt: run.completedAt }
241
+ : {}),
242
+ }
243
+ }
244
+
245
+ function toNodeRunData(nodeRun: PlanNodeRunRecord, patch: PlanNodeRunUpdate) {
246
+ return {
247
+ runId: ensureRecordId(nodeRun.runId, TABLES.PLAN_RUN),
248
+ planSpecId: ensureRecordId(nodeRun.planSpecId, TABLES.PLAN_SPEC),
249
+ nodeId: nodeRun.nodeId,
250
+ status: patch.status ?? nodeRun.status,
251
+ attemptCount: patch.attemptCount ?? nodeRun.attemptCount,
252
+ retryCount: patch.retryCount ?? nodeRun.retryCount,
253
+ ...(patch.resolvedInput === null
254
+ ? {}
255
+ : patch.resolvedInput !== undefined
256
+ ? { resolvedInput: patch.resolvedInput }
257
+ : nodeRun.resolvedInput
258
+ ? { resolvedInput: nodeRun.resolvedInput }
259
+ : {}),
260
+ ...(patch.latestStructuredOutput === null
261
+ ? {}
262
+ : patch.latestStructuredOutput !== undefined
263
+ ? { latestStructuredOutput: patch.latestStructuredOutput }
264
+ : nodeRun.latestStructuredOutput
265
+ ? { latestStructuredOutput: nodeRun.latestStructuredOutput }
266
+ : {}),
267
+ ...(patch.latestNotes === null
268
+ ? {}
269
+ : patch.latestNotes !== undefined
270
+ ? { latestNotes: patch.latestNotes }
271
+ : nodeRun.latestNotes
272
+ ? { latestNotes: nodeRun.latestNotes }
273
+ : {}),
274
+ ...(patch.latestAttemptId === null
275
+ ? {}
276
+ : patch.latestAttemptId !== undefined
277
+ ? { latestAttemptId: ensureRecordId(patch.latestAttemptId, TABLES.PLAN_NODE_ATTEMPT) }
278
+ : nodeRun.latestAttemptId
279
+ ? { latestAttemptId: ensureRecordId(nodeRun.latestAttemptId, TABLES.PLAN_NODE_ATTEMPT) }
280
+ : {}),
281
+ ...(patch.blockedReason === null
282
+ ? {}
283
+ : patch.blockedReason !== undefined
284
+ ? { blockedReason: patch.blockedReason }
285
+ : nodeRun.blockedReason
286
+ ? { blockedReason: nodeRun.blockedReason }
287
+ : {}),
288
+ ...(patch.failureClass === null
289
+ ? {}
290
+ : patch.failureClass !== undefined
291
+ ? { failureClass: patch.failureClass }
292
+ : nodeRun.failureClass
293
+ ? { failureClass: nodeRun.failureClass }
294
+ : {}),
295
+ ...(patch.readyAt === null
296
+ ? {}
297
+ : patch.readyAt !== undefined
298
+ ? { readyAt: patch.readyAt }
299
+ : nodeRun.readyAt
300
+ ? { readyAt: nodeRun.readyAt }
301
+ : {}),
302
+ ...(patch.startedAt === null
303
+ ? {}
304
+ : patch.startedAt !== undefined
305
+ ? { startedAt: patch.startedAt }
306
+ : nodeRun.startedAt
307
+ ? { startedAt: nodeRun.startedAt }
308
+ : {}),
309
+ ...(patch.completedAt === null
310
+ ? {}
311
+ : patch.completedAt !== undefined
312
+ ? { completedAt: patch.completedAt }
313
+ : nodeRun.completedAt
314
+ ? { completedAt: nodeRun.completedAt }
315
+ : {}),
316
+ }
317
+ }
318
+
319
+ function buildToolResult(params: {
320
+ action: ExecutionPlanToolResultData['action']
321
+ plan: SerializableExecutionPlan | null
322
+ message: string
323
+ changedNodeId?: string
324
+ }): ExecutionPlanToolResultData {
325
+ return {
326
+ action: params.action,
327
+ message: params.message,
328
+ ...(params.changedNodeId ? { changedNodeId: params.changedNodeId } : {}),
329
+ ...(params.plan ? { plan: params.plan } : {}),
330
+ hasPlan: params.plan !== null,
331
+ status: params.plan?.status,
332
+ }
333
+ }
334
+
335
+ function deriveApprovalStatus(response: Record<string, unknown>): 'approved' | 'rejected' | 'changes-requested' {
336
+ const approved = response.approved === true
337
+ const requiredEdits = Array.isArray(response.requiredEdits)
338
+ ? response.requiredEdits.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
339
+ : []
340
+
341
+ if (approved && requiredEdits.length === 0) return 'approved'
342
+ if (requiredEdits.length > 0) return 'changes-requested'
343
+ return 'rejected'
344
+ }
345
+
346
+ class PlanExecutorService {
347
+ async submitNodeResult(params: {
348
+ workstreamId: RecordIdInput
349
+ runId: string
350
+ nodeId: string
351
+ emittedBy: string
352
+ result: PlanNodeResultSubmission
353
+ }): Promise<ExecutionPlanToolResultData> {
354
+ const run = await planRunService.getRunById(params.runId)
355
+ if (
356
+ recordIdToString(run.workstreamId, TABLES.WORKSTREAM) !== recordIdToString(params.workstreamId, TABLES.WORKSTREAM)
357
+ ) {
358
+ throw new Error('Execution node result targets a different workstream.')
359
+ }
360
+ if (run.status === 'completed' || run.status === 'failed' || run.status === 'aborted') {
361
+ throw new Error('Execution run is no longer active.')
362
+ }
363
+
364
+ const spec = await planRunService.getPlanSpecById(run.planSpecId)
365
+ const nodeSpecs = await planRunService.listNodeSpecs(spec.id)
366
+ const nodeSpec = nodeSpecs.find((candidate) => candidate.nodeId === params.nodeId)
367
+ if (!nodeSpec) {
368
+ throw new Error(`Execution node "${params.nodeId}" does not exist in this run.`)
369
+ }
370
+ if (isHumanNodeType(nodeSpec.type) || isStructuralNodeType(nodeSpec.type)) {
371
+ throw new Error(
372
+ `Execution node "${nodeSpec.label}" is executor-owned and cannot accept direct result submission.`,
373
+ )
374
+ }
375
+
376
+ const nodeRun = await planRunService.getNodeRunByNodeId(run.id, params.nodeId)
377
+ if (nodeRun.status !== 'running') {
378
+ throw new Error(`Execution node "${nodeSpec.label}" is not currently running.`)
379
+ }
380
+
381
+ const existingArtifacts = await planRunService.listArtifacts(run.id)
382
+ const latestCheckpoint = await planRunService.getLatestCheckpoint(run.id)
383
+ const validation = planValidatorService.validateNodeResult({
384
+ draft: { schemas: spec.schemaRegistry },
385
+ node: {
386
+ id: nodeSpec.nodeId,
387
+ type: nodeSpec.type,
388
+ label: nodeSpec.label,
389
+ owner: nodeSpec.owner,
390
+ objective: nodeSpec.objective,
391
+ instructions: nodeSpec.instructions,
392
+ inputSchemaRef: nodeSpec.inputSchemaRef,
393
+ outputSchemaRef: nodeSpec.outputSchemaRef,
394
+ deliverables: [...nodeSpec.deliverables],
395
+ successCriteria: [...nodeSpec.successCriteria],
396
+ completionChecks: [...nodeSpec.completionChecks],
397
+ retryPolicy: { ...nodeSpec.retryPolicy, retryOn: [...nodeSpec.retryPolicy.retryOn] },
398
+ failurePolicy: [...nodeSpec.failurePolicy],
399
+ timeoutMs: nodeSpec.timeoutMs,
400
+ toolPolicy: { allow: [...nodeSpec.toolPolicy.allow], deny: [...nodeSpec.toolPolicy.deny] },
401
+ contextPolicy: {
402
+ retrievalScopes: [...nodeSpec.contextPolicy.retrievalScopes],
403
+ attachmentPolicy: nodeSpec.contextPolicy.attachmentPolicy,
404
+ webPolicy: nodeSpec.contextPolicy.webPolicy,
405
+ },
406
+ },
407
+ result: params.result,
408
+ })
409
+
410
+ await databaseService.withTransaction(async (tx) => {
411
+ const attempt = await this.createAttempt({
412
+ tx,
413
+ run,
414
+ nodeRun,
415
+ emittedBy: params.emittedBy,
416
+ result: params.result,
417
+ status: validation.blocking.length > 0 ? 'failed' : 'completed',
418
+ failureClass: validation.failureClass,
419
+ })
420
+
421
+ const issues = await this.persistValidationIssues({
422
+ tx,
423
+ run,
424
+ spec,
425
+ attemptId: attempt.id,
426
+ nodeId: params.nodeId,
427
+ issues: [...validation.blocking, ...validation.warnings],
428
+ })
429
+
430
+ const finalizedAttempt = PlanNodeAttemptSchema.parse(
431
+ await tx
432
+ .update(ensureRecordId(attempt.id, TABLES.PLAN_NODE_ATTEMPT))
433
+ .content({
434
+ runId: ensureRecordId(attempt.runId, TABLES.PLAN_RUN),
435
+ nodeRunId: ensureRecordId(attempt.nodeRunId, TABLES.PLAN_NODE_RUN),
436
+ nodeId: attempt.nodeId,
437
+ emittedBy: attempt.emittedBy,
438
+ status: attempt.status,
439
+ ...(attempt.structuredOutput ? { structuredOutput: attempt.structuredOutput } : {}),
440
+ ...(attempt.notes ? { notes: attempt.notes } : {}),
441
+ validationIssueIds: issues.map((issue) => ensureRecordId(issue.id, TABLES.PLAN_VALIDATION_ISSUE)),
442
+ ...(attempt.failureClass ? { failureClass: attempt.failureClass } : {}),
443
+ })
444
+ .output('after'),
445
+ )
446
+
447
+ const persistedArtifacts = await planArtifactService.persistArtifacts({
448
+ tx,
449
+ runId: run.id,
450
+ attemptId: finalizedAttempt.id,
451
+ nodeId: params.nodeId,
452
+ artifacts: params.result.artifacts,
453
+ })
454
+
455
+ let nextNodeRun = PlanNodeRunSchema.parse(
456
+ await tx
457
+ .update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
458
+ .content(
459
+ toNodeRunData(nodeRun, {
460
+ attemptCount: nodeRun.attemptCount + 1,
461
+ latestAttemptId: finalizedAttempt.id,
462
+ latestStructuredOutput: params.result.structuredOutput ?? null,
463
+ latestNotes: params.result.notes ?? null,
464
+ }),
465
+ )
466
+ .output('after'),
467
+ )
468
+
469
+ const nodeRuns = await planRunService.listNodeRuns(run.id)
470
+ const withUpdatedNodeRuns = nodeRuns.map((candidate) =>
471
+ candidate.nodeId === nextNodeRun.nodeId ? nextNodeRun : candidate,
472
+ )
473
+ const nextArtifacts = [...existingArtifacts, ...persistedArtifacts]
474
+
475
+ if (validation.blocking.length > 0) {
476
+ const shouldRetry =
477
+ validation.failureClass &&
478
+ nodeSpec.retryPolicy.maxAttempts > nextNodeRun.retryCount &&
479
+ (nodeSpec.retryPolicy.retryOn.length === 0 || nodeSpec.retryPolicy.retryOn.includes(validation.failureClass))
480
+
481
+ if (shouldRetry) {
482
+ nextNodeRun = PlanNodeRunSchema.parse(
483
+ await tx
484
+ .update(ensureRecordId(nextNodeRun.id, TABLES.PLAN_NODE_RUN))
485
+ .content(
486
+ toNodeRunData(nextNodeRun, {
487
+ status: 'ready',
488
+ retryCount: nextNodeRun.retryCount + 1,
489
+ failureClass: validation.failureClass,
490
+ blockedReason: validation.blocking[0]?.message ?? null,
491
+ readyAt: new Date(),
492
+ startedAt: null,
493
+ completedAt: null,
494
+ }),
495
+ )
496
+ .output('after'),
497
+ )
498
+
499
+ await this.emitEvent({
500
+ tx,
501
+ run,
502
+ spec,
503
+ nodeId: nextNodeRun.nodeId,
504
+ attemptId: finalizedAttempt.id,
505
+ eventType: 'validation-reported',
506
+ message: `Validation failed for node "${nodeSpec.label}", scheduling retry.`,
507
+ detail: { issues: validation.blocking.map((issue) => issue.code) },
508
+ emittedBy: params.emittedBy,
509
+ })
510
+
511
+ const synced = await this.syncRunGraph({
512
+ tx,
513
+ run,
514
+ spec,
515
+ nodeSpecs,
516
+ nodeRuns: withUpdatedNodeRuns.map((candidate) =>
517
+ candidate.nodeId === nextNodeRun.nodeId ? nextNodeRun : candidate,
518
+ ),
519
+ artifacts: nextArtifacts,
520
+ emittedBy: params.emittedBy,
521
+ })
522
+
523
+ const checkpoint = await this.saveCheckpoint({
524
+ tx,
525
+ run: synced.run,
526
+ nodeRuns: synced.nodeRuns,
527
+ artifacts: synced.artifacts,
528
+ sequence: (latestCheckpoint?.sequence ?? 0) + 1,
529
+ reason: 'node-result-retry',
530
+ })
531
+
532
+ await this.attachCheckpoint(tx, synced.run, checkpoint)
533
+ return
534
+ }
535
+
536
+ const failureAction = this.resolveFailureAction(nodeSpec, validation.failureClass)
537
+ if (failureAction === 'human-review') {
538
+ nextNodeRun = PlanNodeRunSchema.parse(
539
+ await tx
540
+ .update(ensureRecordId(nextNodeRun.id, TABLES.PLAN_NODE_RUN))
541
+ .content(
542
+ toNodeRunData(nextNodeRun, {
543
+ status: 'awaiting-human',
544
+ retryCount: nextNodeRun.retryCount + 1,
545
+ failureClass: validation.failureClass,
546
+ blockedReason: validation.blocking[0]?.message ?? null,
547
+ startedAt: nextNodeRun.startedAt ?? new Date(),
548
+ }),
549
+ )
550
+ .output('after'),
551
+ )
552
+
553
+ const approval = await planApprovalService.createPendingApproval({
554
+ tx,
555
+ runId: run.id,
556
+ nodeRunId: nextNodeRun.id,
557
+ nodeId: nextNodeRun.nodeId,
558
+ requestedBy: params.emittedBy,
559
+ presented: {
560
+ nodeId: nodeSpec.nodeId,
561
+ label: nodeSpec.label,
562
+ objective: nodeSpec.objective,
563
+ instructions: nodeSpec.instructions,
564
+ validationIssues: validation.blocking,
565
+ },
566
+ })
567
+
568
+ const failedRun = await this.replaceRun(tx, run, {
569
+ status: 'awaiting-human',
570
+ currentNodeId: nextNodeRun.nodeId,
571
+ waitingNodeId: nextNodeRun.nodeId,
572
+ readyNodeIds: [],
573
+ failureCount: run.failureCount + 1,
574
+ })
575
+
576
+ await this.emitEvent({
577
+ tx,
578
+ run: failedRun,
579
+ spec,
580
+ nodeId: nextNodeRun.nodeId,
581
+ attemptId: finalizedAttempt.id,
582
+ approvalId: approval.id,
583
+ eventType: 'approval-requested',
584
+ fromStatus: run.status,
585
+ toStatus: failedRun.status,
586
+ message: `Node "${nodeSpec.label}" requires human review before continuing.`,
587
+ detail: { issues: validation.blocking.map((issue) => issue.code) },
588
+ emittedBy: params.emittedBy,
589
+ })
590
+
591
+ const checkpoint = await this.saveCheckpoint({
592
+ tx,
593
+ run: failedRun,
594
+ nodeRuns: withUpdatedNodeRuns.map((candidate) =>
595
+ candidate.nodeId === nextNodeRun.nodeId ? nextNodeRun : candidate,
596
+ ),
597
+ artifacts: nextArtifacts,
598
+ sequence: (latestCheckpoint?.sequence ?? 0) + 1,
599
+ reason: 'node-result-human-review',
600
+ })
601
+
602
+ await this.attachCheckpoint(tx, failedRun, checkpoint)
603
+ return
604
+ }
605
+
606
+ if (failureAction === 'replan') {
607
+ nextNodeRun = PlanNodeRunSchema.parse(
608
+ await tx
609
+ .update(ensureRecordId(nextNodeRun.id, TABLES.PLAN_NODE_RUN))
610
+ .content(
611
+ toNodeRunData(nextNodeRun, {
612
+ status: 'blocked',
613
+ retryCount: nextNodeRun.retryCount + 1,
614
+ failureClass: validation.failureClass,
615
+ blockedReason: validation.blocking[0]?.message ?? null,
616
+ }),
617
+ )
618
+ .output('after'),
619
+ )
620
+
621
+ const blockedRun = await this.replaceRun(tx, run, {
622
+ status: 'blocked',
623
+ currentNodeId: nextNodeRun.nodeId,
624
+ waitingNodeId: null,
625
+ readyNodeIds: [],
626
+ failureCount: run.failureCount + 1,
627
+ })
628
+
629
+ await this.emitEvent({
630
+ tx,
631
+ run: blockedRun,
632
+ spec,
633
+ nodeId: nextNodeRun.nodeId,
634
+ attemptId: finalizedAttempt.id,
635
+ eventType: 'node-blocked',
636
+ fromStatus: nodeRun.status,
637
+ toStatus: nextNodeRun.status,
638
+ message: `Node "${nodeSpec.label}" failed validation and requires replanning.`,
639
+ detail: { failureClass: validation.failureClass, issues: validation.blocking.map((issue) => issue.code) },
640
+ emittedBy: params.emittedBy,
641
+ })
642
+
643
+ const checkpoint = await this.saveCheckpoint({
644
+ tx,
645
+ run: blockedRun,
646
+ nodeRuns: withUpdatedNodeRuns.map((candidate) =>
647
+ candidate.nodeId === nextNodeRun.nodeId ? nextNodeRun : candidate,
648
+ ),
649
+ artifacts: nextArtifacts,
650
+ sequence: (latestCheckpoint?.sequence ?? 0) + 1,
651
+ reason: 'node-result-replan',
652
+ })
653
+
654
+ await this.attachCheckpoint(tx, blockedRun, checkpoint)
655
+ return
656
+ }
657
+
658
+ nextNodeRun = PlanNodeRunSchema.parse(
659
+ await tx
660
+ .update(ensureRecordId(nextNodeRun.id, TABLES.PLAN_NODE_RUN))
661
+ .content(
662
+ toNodeRunData(nextNodeRun, {
663
+ status: 'failed',
664
+ retryCount: nextNodeRun.retryCount + 1,
665
+ failureClass: validation.failureClass,
666
+ blockedReason: validation.blocking[0]?.message ?? null,
667
+ completedAt: new Date(),
668
+ }),
669
+ )
670
+ .output('after'),
671
+ )
672
+
673
+ const failedRun = await this.replaceRun(tx, run, {
674
+ status: 'failed',
675
+ currentNodeId: null,
676
+ waitingNodeId: null,
677
+ readyNodeIds: [],
678
+ failureCount: run.failureCount + 1,
679
+ completedAt: new Date(),
680
+ })
681
+
682
+ await this.emitEvent({
683
+ tx,
684
+ run: failedRun,
685
+ spec,
686
+ nodeId: nextNodeRun.nodeId,
687
+ attemptId: finalizedAttempt.id,
688
+ eventType: 'node-failed',
689
+ fromStatus: nodeRun.status,
690
+ toStatus: nextNodeRun.status,
691
+ message: `Node "${nodeSpec.label}" failed validation and the run has been aborted.`,
692
+ detail: { failureClass: validation.failureClass, issues: validation.blocking.map((issue) => issue.code) },
693
+ emittedBy: params.emittedBy,
694
+ })
695
+
696
+ const checkpoint = await this.saveCheckpoint({
697
+ tx,
698
+ run: failedRun,
699
+ nodeRuns: withUpdatedNodeRuns.map((candidate) =>
700
+ candidate.nodeId === nextNodeRun.nodeId ? nextNodeRun : candidate,
701
+ ),
702
+ artifacts: nextArtifacts,
703
+ sequence: (latestCheckpoint?.sequence ?? 0) + 1,
704
+ reason: 'node-result-failed',
705
+ })
706
+
707
+ await this.attachCheckpoint(tx, failedRun, checkpoint)
708
+ return
709
+ }
710
+
711
+ nextNodeRun = PlanNodeRunSchema.parse(
712
+ await tx
713
+ .update(ensureRecordId(nextNodeRun.id, TABLES.PLAN_NODE_RUN))
714
+ .content(
715
+ toNodeRunData(nextNodeRun, {
716
+ status: validation.warnings.length > 0 ? 'partial' : 'completed',
717
+ latestAttemptId: finalizedAttempt.id,
718
+ latestStructuredOutput: params.result.structuredOutput ?? null,
719
+ latestNotes: params.result.notes ?? null,
720
+ blockedReason: null,
721
+ failureClass: null,
722
+ completedAt: new Date(),
723
+ }),
724
+ )
725
+ .output('after'),
726
+ )
727
+
728
+ await this.emitEvent({
729
+ tx,
730
+ run,
731
+ spec,
732
+ nodeId: nextNodeRun.nodeId,
733
+ attemptId: finalizedAttempt.id,
734
+ eventType: validation.warnings.length > 0 ? 'node-partial' : 'node-completed',
735
+ fromStatus: nodeRun.status,
736
+ toStatus: nextNodeRun.status,
737
+ message:
738
+ validation.warnings.length > 0
739
+ ? `Node "${nodeSpec.label}" completed with warnings.`
740
+ : `Node "${nodeSpec.label}" completed successfully.`,
741
+ detail: { warningCount: validation.warnings.length },
742
+ emittedBy: params.emittedBy,
743
+ })
744
+
745
+ const synced = await this.syncRunGraph({
746
+ tx,
747
+ run,
748
+ spec,
749
+ nodeSpecs,
750
+ nodeRuns: withUpdatedNodeRuns.map((candidate) =>
751
+ candidate.nodeId === nextNodeRun.nodeId ? nextNodeRun : candidate,
752
+ ),
753
+ artifacts: nextArtifacts,
754
+ emittedBy: params.emittedBy,
755
+ })
756
+
757
+ const checkpoint = await this.saveCheckpoint({
758
+ tx,
759
+ run: synced.run,
760
+ nodeRuns: synced.nodeRuns,
761
+ artifacts: synced.artifacts,
762
+ sequence: (latestCheckpoint?.sequence ?? 0) + 1,
763
+ reason: 'node-result-complete',
764
+ })
765
+
766
+ await this.attachCheckpoint(tx, synced.run, checkpoint)
767
+ })
768
+
769
+ const snapshot = await planRunService.toSerializablePlan(await planRunService.getRunById(run.id), {
770
+ includeEvents: true,
771
+ includeArtifacts: true,
772
+ includeApprovals: true,
773
+ includeCheckpoints: true,
774
+ includeValidationIssues: true,
775
+ })
776
+
777
+ return buildToolResult({
778
+ action: 'node-result-submitted',
779
+ plan: snapshot,
780
+ message: `Submitted result for node "${nodeSpec.label}".`,
781
+ changedNodeId: params.nodeId,
782
+ })
783
+ }
784
+
785
+ async submitHumanNodeResponse(params: {
786
+ workstreamId: RecordIdInput
787
+ approvalId?: string
788
+ respondedBy: string
789
+ response: Record<string, unknown>
790
+ approvalMessageId?: string
791
+ }): Promise<SerializableExecutionPlan | null> {
792
+ const run = await planRunService.getActiveRunRecord(params.workstreamId)
793
+ if (!run || run.status !== 'awaiting-human' || !run.waitingNodeId) {
794
+ return null
795
+ }
796
+
797
+ const spec = await planRunService.getPlanSpecById(run.planSpecId)
798
+ const nodeSpecs = await planRunService.listNodeSpecs(spec.id)
799
+ const nodeSpec = nodeSpecs.find((candidate) => candidate.nodeId === run.waitingNodeId)
800
+ if (!nodeSpec) {
801
+ throw new Error(`Waiting node "${run.waitingNodeId}" does not exist.`)
802
+ }
803
+
804
+ const nodeRun = await planRunService.getNodeRunByNodeId(run.id, run.waitingNodeId)
805
+ const approval =
806
+ (params.approvalId ? await planApprovalService.getApprovalById(params.approvalId) : null) ??
807
+ (await planApprovalService.getPendingApprovalForNodeRun(nodeRun.id))
808
+ if (!approval) {
809
+ throw new Error(`No pending approval exists for node "${nodeSpec.label}".`)
810
+ }
811
+
812
+ const existingArtifacts = await planRunService.listArtifacts(run.id)
813
+ const latestCheckpoint = await planRunService.getLatestCheckpoint(run.id)
814
+ const validation = planValidatorService.validateNodeResult({
815
+ draft: { schemas: spec.schemaRegistry },
816
+ node: {
817
+ id: nodeSpec.nodeId,
818
+ type: nodeSpec.type,
819
+ label: nodeSpec.label,
820
+ owner: nodeSpec.owner,
821
+ objective: nodeSpec.objective,
822
+ instructions: nodeSpec.instructions,
823
+ inputSchemaRef: nodeSpec.inputSchemaRef,
824
+ outputSchemaRef: nodeSpec.outputSchemaRef,
825
+ deliverables: [...nodeSpec.deliverables],
826
+ successCriteria: [...nodeSpec.successCriteria],
827
+ completionChecks: [...nodeSpec.completionChecks],
828
+ retryPolicy: { ...nodeSpec.retryPolicy, retryOn: [...nodeSpec.retryPolicy.retryOn] },
829
+ failurePolicy: [...nodeSpec.failurePolicy],
830
+ timeoutMs: nodeSpec.timeoutMs,
831
+ toolPolicy: { allow: [...nodeSpec.toolPolicy.allow], deny: [...nodeSpec.toolPolicy.deny] },
832
+ contextPolicy: {
833
+ retrievalScopes: [...nodeSpec.contextPolicy.retrievalScopes],
834
+ attachmentPolicy: nodeSpec.contextPolicy.attachmentPolicy,
835
+ webPolicy: nodeSpec.contextPolicy.webPolicy,
836
+ },
837
+ },
838
+ result: {
839
+ structuredOutput: params.response,
840
+ artifacts: [],
841
+ notes: typeof params.response.comments === 'string' ? params.response.comments : undefined,
842
+ },
843
+ })
844
+
845
+ await databaseService.withTransaction(async (tx) => {
846
+ const approvalStatus = deriveApprovalStatus(params.response)
847
+ await planApprovalService.updateApprovalResponse({
848
+ tx,
849
+ approval,
850
+ status: approvalStatus,
851
+ response: params.response,
852
+ respondedBy: params.respondedBy,
853
+ approvalMessageId: params.approvalMessageId,
854
+ comments: typeof params.response.comments === 'string' ? params.response.comments : undefined,
855
+ requiredEdits: Array.isArray(params.response.requiredEdits)
856
+ ? params.response.requiredEdits.filter((entry): entry is string => typeof entry === 'string')
857
+ : undefined,
858
+ })
859
+
860
+ const attempt = await this.createAttempt({
861
+ tx,
862
+ run,
863
+ nodeRun,
864
+ emittedBy: params.respondedBy,
865
+ result: {
866
+ structuredOutput: params.response,
867
+ artifacts: [],
868
+ notes: typeof params.response.comments === 'string' ? params.response.comments : undefined,
869
+ },
870
+ status: validation.blocking.length > 0 ? 'failed' : 'completed',
871
+ failureClass: validation.failureClass,
872
+ })
873
+
874
+ const issues = await this.persistValidationIssues({
875
+ tx,
876
+ run,
877
+ spec,
878
+ attemptId: attempt.id,
879
+ nodeId: nodeRun.nodeId,
880
+ issues: [...validation.blocking, ...validation.warnings],
881
+ })
882
+
883
+ await tx
884
+ .update(ensureRecordId(attempt.id, TABLES.PLAN_NODE_ATTEMPT))
885
+ .content({
886
+ runId: ensureRecordId(attempt.runId, TABLES.PLAN_RUN),
887
+ nodeRunId: ensureRecordId(attempt.nodeRunId, TABLES.PLAN_NODE_RUN),
888
+ nodeId: attempt.nodeId,
889
+ emittedBy: attempt.emittedBy,
890
+ status: attempt.status,
891
+ structuredOutput: params.response,
892
+ validationIssueIds: issues.map((issue) => ensureRecordId(issue.id, TABLES.PLAN_VALIDATION_ISSUE)),
893
+ ...(attempt.notes ? { notes: attempt.notes } : {}),
894
+ ...(attempt.failureClass ? { failureClass: attempt.failureClass } : {}),
895
+ })
896
+ .output('after')
897
+
898
+ const nextNodeRun =
899
+ validation.blocking.length > 0
900
+ ? PlanNodeRunSchema.parse(
901
+ await tx
902
+ .update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
903
+ .content(
904
+ toNodeRunData(nodeRun, {
905
+ status: 'blocked',
906
+ attemptCount: nodeRun.attemptCount + 1,
907
+ latestAttemptId: attempt.id,
908
+ latestStructuredOutput: params.response,
909
+ latestNotes: typeof params.response.comments === 'string' ? params.response.comments : null,
910
+ blockedReason: validation.blocking[0]?.message ?? null,
911
+ failureClass: validation.failureClass,
912
+ }),
913
+ )
914
+ .output('after'),
915
+ )
916
+ : PlanNodeRunSchema.parse(
917
+ await tx
918
+ .update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
919
+ .content(
920
+ toNodeRunData(nodeRun, {
921
+ status: 'completed',
922
+ attemptCount: nodeRun.attemptCount + 1,
923
+ latestAttemptId: attempt.id,
924
+ latestStructuredOutput: params.response,
925
+ latestNotes: typeof params.response.comments === 'string' ? params.response.comments : null,
926
+ blockedReason: null,
927
+ failureClass: null,
928
+ completedAt: new Date(),
929
+ }),
930
+ )
931
+ .output('after'),
932
+ )
933
+
934
+ const nodeRuns = (await planRunService.listNodeRuns(run.id)).map((candidate) =>
935
+ candidate.nodeId === nextNodeRun.nodeId ? nextNodeRun : candidate,
936
+ )
937
+
938
+ if (validation.blocking.length > 0) {
939
+ const blockedRun = await this.replaceRun(tx, run, {
940
+ status: 'blocked',
941
+ currentNodeId: nextNodeRun.nodeId,
942
+ waitingNodeId: null,
943
+ readyNodeIds: [],
944
+ failureCount: run.failureCount + 1,
945
+ })
946
+
947
+ await this.emitEvent({
948
+ tx,
949
+ run: blockedRun,
950
+ spec,
951
+ nodeId: nextNodeRun.nodeId,
952
+ attemptId: attempt.id,
953
+ approvalId: approval.id,
954
+ eventType: 'approval-resolved',
955
+ fromStatus: run.status,
956
+ toStatus: blockedRun.status,
957
+ message: `Human response for node "${nodeSpec.label}" blocked execution.`,
958
+ detail: { issues: validation.blocking.map((issue) => issue.code) },
959
+ emittedBy: params.respondedBy,
960
+ })
961
+
962
+ const checkpoint = await this.saveCheckpoint({
963
+ tx,
964
+ run: blockedRun,
965
+ nodeRuns,
966
+ artifacts: existingArtifacts,
967
+ sequence: (latestCheckpoint?.sequence ?? 0) + 1,
968
+ reason: 'human-response-blocked',
969
+ })
970
+ await this.attachCheckpoint(tx, blockedRun, checkpoint)
971
+ return
972
+ }
973
+
974
+ const synced = await this.syncRunGraph({
975
+ tx,
976
+ run,
977
+ spec,
978
+ nodeSpecs,
979
+ nodeRuns,
980
+ artifacts: existingArtifacts,
981
+ emittedBy: params.respondedBy,
982
+ })
983
+
984
+ await this.emitEvent({
985
+ tx,
986
+ run: synced.run,
987
+ spec,
988
+ nodeId: nextNodeRun.nodeId,
989
+ attemptId: attempt.id,
990
+ approvalId: approval.id,
991
+ eventType: 'approval-resolved',
992
+ fromStatus: run.status,
993
+ toStatus: synced.run.status,
994
+ message: `Human response for node "${nodeSpec.label}" accepted.`,
995
+ detail: { approvalStatus: approvalStatus },
996
+ emittedBy: params.respondedBy,
997
+ })
998
+
999
+ const checkpoint = await this.saveCheckpoint({
1000
+ tx,
1001
+ run: synced.run,
1002
+ nodeRuns: synced.nodeRuns,
1003
+ artifacts: synced.artifacts,
1004
+ sequence: (latestCheckpoint?.sequence ?? 0) + 1,
1005
+ reason: 'human-response-complete',
1006
+ })
1007
+ await this.attachCheckpoint(tx, synced.run, checkpoint)
1008
+ })
1009
+
1010
+ return await planRunService.toSerializablePlan(await planRunService.getRunById(run.id), {
1011
+ includeEvents: true,
1012
+ includeArtifacts: true,
1013
+ includeApprovals: true,
1014
+ includeCheckpoints: true,
1015
+ includeValidationIssues: true,
1016
+ })
1017
+ }
1018
+
1019
+ async resumeRun(params: {
1020
+ workstreamId: RecordIdInput
1021
+ runId: string
1022
+ emittedBy: string
1023
+ }): Promise<ExecutionPlanToolResultData> {
1024
+ const run = await planRunService.getRunById(params.runId)
1025
+ if (
1026
+ recordIdToString(run.workstreamId, TABLES.WORKSTREAM) !== recordIdToString(params.workstreamId, TABLES.WORKSTREAM)
1027
+ ) {
1028
+ throw new Error('Execution run belongs to a different workstream.')
1029
+ }
1030
+
1031
+ const spec = await planRunService.getPlanSpecById(run.planSpecId)
1032
+ const nodeSpecs = await planRunService.listNodeSpecs(spec.id)
1033
+ const nodeRuns = await planRunService.listNodeRuns(run.id)
1034
+ const artifacts = await planRunService.listArtifacts(run.id)
1035
+ const latestCheckpoint = await planRunService.getLatestCheckpoint(run.id)
1036
+
1037
+ await databaseService.withTransaction(async (tx) => {
1038
+ let currentNodeRuns = [...nodeRuns]
1039
+ for (const nodeRun of currentNodeRuns.filter((candidate) => candidate.status === 'running')) {
1040
+ const resetNodeRun = PlanNodeRunSchema.parse(
1041
+ await tx
1042
+ .update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
1043
+ .content(
1044
+ toNodeRunData(nodeRun, {
1045
+ status: 'ready',
1046
+ readyAt: new Date(),
1047
+ startedAt: nodeRun.startedAt ?? new Date(),
1048
+ }),
1049
+ )
1050
+ .output('after'),
1051
+ )
1052
+ currentNodeRuns = currentNodeRuns.map((candidate) =>
1053
+ candidate.nodeId === resetNodeRun.nodeId ? resetNodeRun : candidate,
1054
+ )
1055
+ }
1056
+
1057
+ const resetRun = await this.replaceRun(tx, run, {
1058
+ status: run.status === 'awaiting-human' ? 'awaiting-human' : 'running',
1059
+ currentNodeId: run.status === 'awaiting-human' ? (run.currentNodeId ?? null) : null,
1060
+ waitingNodeId: run.status === 'awaiting-human' ? (run.waitingNodeId ?? null) : null,
1061
+ readyNodeIds: currentNodeRuns
1062
+ .filter((candidate) => candidate.status === 'ready')
1063
+ .map((candidate) => candidate.nodeId),
1064
+ })
1065
+
1066
+ await this.emitEvent({
1067
+ tx,
1068
+ run: resetRun,
1069
+ spec,
1070
+ eventType: 'run-resumed',
1071
+ fromStatus: run.status,
1072
+ toStatus: resetRun.status,
1073
+ message: `Run "${spec.title}" resumed from the latest checkpoint.`,
1074
+ detail: latestCheckpoint ? { checkpointId: recordIdToString(latestCheckpoint.id, TABLES.PLAN_CHECKPOINT) } : {},
1075
+ emittedBy: params.emittedBy,
1076
+ })
1077
+
1078
+ const synced =
1079
+ resetRun.status === 'awaiting-human'
1080
+ ? { run: resetRun, nodeRuns: currentNodeRuns, artifacts }
1081
+ : await this.syncRunGraph({
1082
+ tx,
1083
+ run: resetRun,
1084
+ spec,
1085
+ nodeSpecs,
1086
+ nodeRuns: currentNodeRuns,
1087
+ artifacts,
1088
+ emittedBy: params.emittedBy,
1089
+ })
1090
+
1091
+ const checkpoint = await this.saveCheckpoint({
1092
+ tx,
1093
+ run: synced.run,
1094
+ nodeRuns: synced.nodeRuns,
1095
+ artifacts: synced.artifacts,
1096
+ sequence: (latestCheckpoint?.sequence ?? 0) + 1,
1097
+ reason: 'run-resumed',
1098
+ })
1099
+ await this.attachCheckpoint(tx, synced.run, checkpoint)
1100
+ })
1101
+
1102
+ const snapshot = await planRunService.toSerializablePlan(await planRunService.getRunById(run.id), {
1103
+ includeEvents: true,
1104
+ includeArtifacts: true,
1105
+ includeApprovals: true,
1106
+ includeCheckpoints: true,
1107
+ includeValidationIssues: true,
1108
+ })
1109
+
1110
+ return buildToolResult({
1111
+ action: 'run-resumed',
1112
+ plan: snapshot,
1113
+ message: `Resumed execution run "${snapshot.title}".`,
1114
+ })
1115
+ }
1116
+
1117
+ async syncRunGraph(params: {
1118
+ tx: DatabaseTransaction
1119
+ run: PlanRunRecord
1120
+ spec: PlanSpecRecord
1121
+ nodeSpecs: PlanNodeSpecRecord[]
1122
+ nodeRuns: PlanNodeRunRecord[]
1123
+ artifacts: Array<{
1124
+ id: RecordIdInput
1125
+ nodeId: string
1126
+ name: string
1127
+ kind: string
1128
+ pointer: string
1129
+ schemaRef?: string
1130
+ payload?: unknown
1131
+ }>
1132
+ emittedBy: string
1133
+ }): Promise<{
1134
+ run: PlanRunRecord
1135
+ nodeRuns: PlanNodeRunRecord[]
1136
+ artifacts: Array<{
1137
+ id: RecordIdInput
1138
+ nodeId: string
1139
+ name: string
1140
+ kind: string
1141
+ pointer: string
1142
+ schemaRef?: string
1143
+ payload?: unknown
1144
+ }>
1145
+ }> {
1146
+ let currentRun = params.run
1147
+ let currentNodeRuns = [...params.nodeRuns]
1148
+ const currentArtifacts = [...params.artifacts]
1149
+ const sortedNodeSpecs = [...params.nodeSpecs].sort((left, right) => left.position - right.position)
1150
+
1151
+ const replaceNodeRun = (nextNodeRun: PlanNodeRunRecord) => {
1152
+ currentNodeRuns = currentNodeRuns.map((candidate) =>
1153
+ candidate.nodeId === nextNodeRun.nodeId ? nextNodeRun : candidate,
1154
+ )
1155
+ }
1156
+
1157
+ const getNodeRunsById = () => new Map(currentNodeRuns.map((nodeRun) => [nodeRun.nodeId, nodeRun]))
1158
+ const getArtifactsByNodeId = () =>
1159
+ currentArtifacts.reduce((groups, artifact) => {
1160
+ const list = groups.get(artifact.nodeId) ?? []
1161
+ list.push(artifact)
1162
+ groups.set(artifact.nodeId, list)
1163
+ return groups
1164
+ }, new Map<string, typeof currentArtifacts>())
1165
+
1166
+ let changed = true
1167
+ while (changed) {
1168
+ changed = false
1169
+ const nodeRunsById = getNodeRunsById()
1170
+ const artifactsByNodeId = getArtifactsByNodeId()
1171
+
1172
+ for (const nodeSpec of sortedNodeSpecs) {
1173
+ const nodeRun = nodeRunsById.get(nodeSpec.nodeId)
1174
+ if (!nodeRun || nodeRun.status !== 'pending') continue
1175
+
1176
+ const upstreamRuns = nodeSpec.upstreamNodeIds
1177
+ .map((nodeId) => nodeRunsById.get(nodeId))
1178
+ .filter(Boolean) as PlanNodeRunRecord[]
1179
+ if (
1180
+ nodeSpec.upstreamNodeIds.length > 0 &&
1181
+ !upstreamRuns.every((upstreamRun) => isSuccessfulTerminalStatus(upstreamRun.status))
1182
+ ) {
1183
+ continue
1184
+ }
1185
+
1186
+ const activeIncomingEdges = params.spec.edges.filter((edge) => {
1187
+ if (edge.target !== nodeSpec.nodeId) return false
1188
+ const sourceRun = nodeRunsById.get(edge.source)
1189
+ if (!sourceRun) return false
1190
+ const context = buildNodeContext({ nodeRun: sourceRun, artifacts: artifactsByNodeId.get(edge.source) ?? [] })
1191
+ return evaluateCondition(edge.when, context)
1192
+ })
1193
+
1194
+ if (nodeSpec.upstreamNodeIds.length > 0 && activeIncomingEdges.length === 0) {
1195
+ const skippedNodeRun = PlanNodeRunSchema.parse(
1196
+ await params.tx
1197
+ .update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
1198
+ .content(
1199
+ toNodeRunData(nodeRun, {
1200
+ status: 'skipped',
1201
+ completedAt: new Date(),
1202
+ blockedReason: null,
1203
+ failureClass: null,
1204
+ }),
1205
+ )
1206
+ .output('after'),
1207
+ )
1208
+ replaceNodeRun(skippedNodeRun)
1209
+ await this.emitEvent({
1210
+ tx: params.tx,
1211
+ run: currentRun,
1212
+ spec: params.spec,
1213
+ nodeId: skippedNodeRun.nodeId,
1214
+ eventType: 'node-skipped',
1215
+ fromStatus: nodeRun.status,
1216
+ toStatus: skippedNodeRun.status,
1217
+ message: `Node "${nodeSpec.label}" was skipped because no inbound branch was activated.`,
1218
+ emittedBy: params.emittedBy,
1219
+ })
1220
+ changed = true
1221
+ continue
1222
+ }
1223
+
1224
+ const resolvedInput = this.buildResolvedInput({ spec: params.spec, nodeSpec, nodeRunsById, artifactsByNodeId })
1225
+ const readyNodeRun = PlanNodeRunSchema.parse(
1226
+ await params.tx
1227
+ .update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
1228
+ .content(toNodeRunData(nodeRun, { status: 'ready', resolvedInput, readyAt: new Date() }))
1229
+ .output('after'),
1230
+ )
1231
+ replaceNodeRun(readyNodeRun)
1232
+ await this.emitEvent({
1233
+ tx: params.tx,
1234
+ run: currentRun,
1235
+ spec: params.spec,
1236
+ nodeId: readyNodeRun.nodeId,
1237
+ eventType: 'node-ready',
1238
+ fromStatus: nodeRun.status,
1239
+ toStatus: readyNodeRun.status,
1240
+ message: `Node "${nodeSpec.label}" is ready to execute.`,
1241
+ emittedBy: params.emittedBy,
1242
+ })
1243
+ changed = true
1244
+ }
1245
+
1246
+ const readyStructuralNodes = sortedNodeSpecs.filter((nodeSpec) => {
1247
+ const nodeRun = getNodeRunsById().get(nodeSpec.nodeId)
1248
+ return nodeRun?.status === 'ready' && isStructuralNodeType(nodeSpec.type)
1249
+ })
1250
+
1251
+ for (const nodeSpec of readyStructuralNodes) {
1252
+ const nodeRun = getNodeRunsById().get(nodeSpec.nodeId)
1253
+ if (!nodeRun) continue
1254
+
1255
+ const completedNodeRun = PlanNodeRunSchema.parse(
1256
+ await params.tx
1257
+ .update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
1258
+ .content(
1259
+ toNodeRunData(nodeRun, {
1260
+ status: 'completed',
1261
+ startedAt: nodeRun.startedAt ?? new Date(),
1262
+ completedAt: new Date(),
1263
+ }),
1264
+ )
1265
+ .output('after'),
1266
+ )
1267
+ replaceNodeRun(completedNodeRun)
1268
+ await this.emitEvent({
1269
+ tx: params.tx,
1270
+ run: currentRun,
1271
+ spec: params.spec,
1272
+ nodeId: completedNodeRun.nodeId,
1273
+ eventType: 'node-auto-completed',
1274
+ fromStatus: nodeRun.status,
1275
+ toStatus: completedNodeRun.status,
1276
+ message: `Structural node "${nodeSpec.label}" auto-completed.`,
1277
+ emittedBy: params.emittedBy,
1278
+ })
1279
+ changed = true
1280
+ }
1281
+ }
1282
+
1283
+ const nodeRunsById = getNodeRunsById()
1284
+ const activeRunningNode = currentNodeRuns.find((nodeRun) => nodeRun.status === 'running')
1285
+ const activeHumanNode = currentNodeRuns.find((nodeRun) => nodeRun.status === 'awaiting-human')
1286
+
1287
+ if (!activeRunningNode && !activeHumanNode) {
1288
+ const nextHumanNodeSpec = sortedNodeSpecs.find((nodeSpec) => {
1289
+ const nodeRun = nodeRunsById.get(nodeSpec.nodeId)
1290
+ return nodeRun?.status === 'ready' && isHumanNodeType(nodeSpec.type)
1291
+ })
1292
+
1293
+ if (nextHumanNodeSpec) {
1294
+ const nodeRun = nodeRunsById.get(nextHumanNodeSpec.nodeId)
1295
+ if (!nodeRun) {
1296
+ throw new Error(`Expected ready node run for "${nextHumanNodeSpec.nodeId}".`)
1297
+ }
1298
+ const awaitingHumanNodeRun = PlanNodeRunSchema.parse(
1299
+ await params.tx
1300
+ .update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
1301
+ .content(toNodeRunData(nodeRun, { status: 'awaiting-human', startedAt: nodeRun.startedAt ?? new Date() }))
1302
+ .output('after'),
1303
+ )
1304
+ replaceNodeRun(awaitingHumanNodeRun)
1305
+
1306
+ const approval = await planApprovalService.createPendingApproval({
1307
+ tx: params.tx,
1308
+ runId: currentRun.id,
1309
+ nodeRunId: awaitingHumanNodeRun.id,
1310
+ nodeId: awaitingHumanNodeRun.nodeId,
1311
+ requestedBy: params.emittedBy,
1312
+ presented: {
1313
+ nodeId: nextHumanNodeSpec.nodeId,
1314
+ label: nextHumanNodeSpec.label,
1315
+ objective: nextHumanNodeSpec.objective,
1316
+ instructions: nextHumanNodeSpec.instructions,
1317
+ deliverables: nextHumanNodeSpec.deliverables,
1318
+ successCriteria: nextHumanNodeSpec.successCriteria,
1319
+ resolvedInput: awaitingHumanNodeRun.resolvedInput ?? {},
1320
+ },
1321
+ })
1322
+
1323
+ currentRun = await this.replaceRun(params.tx, currentRun, {
1324
+ status: 'awaiting-human',
1325
+ currentNodeId: awaitingHumanNodeRun.nodeId,
1326
+ waitingNodeId: awaitingHumanNodeRun.nodeId,
1327
+ readyNodeIds: currentNodeRuns
1328
+ .filter((candidate) => candidate.status === 'ready' && candidate.nodeId !== awaitingHumanNodeRun.nodeId)
1329
+ .map((candidate) => candidate.nodeId),
1330
+ })
1331
+
1332
+ await this.emitEvent({
1333
+ tx: params.tx,
1334
+ run: currentRun,
1335
+ spec: params.spec,
1336
+ nodeId: awaitingHumanNodeRun.nodeId,
1337
+ approvalId: approval.id,
1338
+ eventType: 'approval-requested',
1339
+ fromStatus: params.run.status,
1340
+ toStatus: currentRun.status,
1341
+ message: `Node "${nextHumanNodeSpec.label}" is awaiting human input.`,
1342
+ emittedBy: params.emittedBy,
1343
+ })
1344
+ } else {
1345
+ const nextActionNodeSpec = sortedNodeSpecs.find((nodeSpec) => {
1346
+ const nodeRun = nodeRunsById.get(nodeSpec.nodeId)
1347
+ return nodeRun?.status === 'ready' && !isStructuralNodeType(nodeSpec.type)
1348
+ })
1349
+
1350
+ if (nextActionNodeSpec) {
1351
+ const nodeRun = nodeRunsById.get(nextActionNodeSpec.nodeId)
1352
+ if (!nodeRun) {
1353
+ throw new Error(`Expected ready node run for "${nextActionNodeSpec.nodeId}".`)
1354
+ }
1355
+ const runningNodeRun = PlanNodeRunSchema.parse(
1356
+ await params.tx
1357
+ .update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
1358
+ .content(toNodeRunData(nodeRun, { status: 'running', startedAt: nodeRun.startedAt ?? new Date() }))
1359
+ .output('after'),
1360
+ )
1361
+ replaceNodeRun(runningNodeRun)
1362
+
1363
+ currentRun = await this.replaceRun(params.tx, currentRun, {
1364
+ status: 'running',
1365
+ currentNodeId: runningNodeRun.nodeId,
1366
+ waitingNodeId: null,
1367
+ readyNodeIds: currentNodeRuns
1368
+ .filter((candidate) => candidate.status === 'ready' && candidate.nodeId !== runningNodeRun.nodeId)
1369
+ .map((candidate) => candidate.nodeId),
1370
+ })
1371
+
1372
+ await this.emitEvent({
1373
+ tx: params.tx,
1374
+ run: currentRun,
1375
+ spec: params.spec,
1376
+ nodeId: runningNodeRun.nodeId,
1377
+ eventType: 'node-running',
1378
+ fromStatus: nodeRun.status,
1379
+ toStatus: runningNodeRun.status,
1380
+ message: `Node "${nextActionNodeSpec.label}" is now running.`,
1381
+ emittedBy: params.emittedBy,
1382
+ })
1383
+ } else if (currentNodeRuns.every((nodeRun) => isSuccessfulTerminalStatus(nodeRun.status))) {
1384
+ currentRun = await this.replaceRun(params.tx, currentRun, {
1385
+ status: 'completed',
1386
+ currentNodeId: null,
1387
+ waitingNodeId: null,
1388
+ readyNodeIds: [],
1389
+ completedAt: new Date(),
1390
+ })
1391
+
1392
+ await this.emitEvent({
1393
+ tx: params.tx,
1394
+ run: currentRun,
1395
+ spec: params.spec,
1396
+ eventType: 'run-status-changed',
1397
+ fromStatus: params.run.status,
1398
+ toStatus: currentRun.status,
1399
+ message: `Run "${params.spec.title}" completed.`,
1400
+ emittedBy: params.emittedBy,
1401
+ })
1402
+ } else {
1403
+ currentRun = await this.replaceRun(params.tx, currentRun, {
1404
+ status: 'blocked',
1405
+ currentNodeId: null,
1406
+ waitingNodeId: null,
1407
+ readyNodeIds: currentNodeRuns
1408
+ .filter((candidate) => candidate.status === 'ready')
1409
+ .map((candidate) => candidate.nodeId),
1410
+ })
1411
+ }
1412
+ }
1413
+ } else {
1414
+ currentRun = await this.replaceRun(params.tx, currentRun, {
1415
+ status: activeHumanNode ? 'awaiting-human' : 'running',
1416
+ currentNodeId: activeHumanNode?.nodeId ?? activeRunningNode?.nodeId ?? null,
1417
+ waitingNodeId: activeHumanNode?.nodeId ?? null,
1418
+ readyNodeIds: currentNodeRuns
1419
+ .filter((candidate) => candidate.status === 'ready')
1420
+ .map((candidate) => candidate.nodeId),
1421
+ })
1422
+ }
1423
+
1424
+ return { run: currentRun, nodeRuns: currentNodeRuns, artifacts: currentArtifacts }
1425
+ }
1426
+
1427
+ private resolveFailureAction(nodeSpec: PlanNodeSpecRecord, failureClass: PlanFailureClass | null): PlanFailureAction {
1428
+ if (!failureClass) return 'abort'
1429
+
1430
+ const matchedRule = nodeSpec.failurePolicy.find((rule) => rule.on === failureClass)
1431
+ return matchedRule?.action ?? 'abort'
1432
+ }
1433
+
1434
+ private buildResolvedInput(params: {
1435
+ spec: PlanSpecRecord
1436
+ nodeSpec: PlanNodeSpecRecord
1437
+ nodeRunsById: Map<string, PlanNodeRunRecord>
1438
+ artifactsByNodeId: Map<
1439
+ string,
1440
+ Array<{ nodeId: string; name: string; kind: string; pointer: string; schemaRef?: string; payload?: unknown }>
1441
+ >
1442
+ }) {
1443
+ const resolvedInput: Record<string, unknown> = {}
1444
+
1445
+ for (const edge of params.spec.edges.filter((candidate) => candidate.target === params.nodeSpec.nodeId)) {
1446
+ const sourceRun = params.nodeRunsById.get(edge.source)
1447
+ if (!sourceRun || !isSuccessfulTerminalStatus(sourceRun.status)) continue
1448
+
1449
+ const context = buildNodeContext({
1450
+ nodeRun: sourceRun,
1451
+ artifacts: params.artifactsByNodeId.get(edge.source) ?? [],
1452
+ })
1453
+ if (!evaluateCondition(edge.when, context)) continue
1454
+
1455
+ for (const [targetPath, sourcePath] of Object.entries(edge.map)) {
1456
+ const value = readPathValue(context, sourcePath)
1457
+ if (value !== undefined) {
1458
+ setPathValue(resolvedInput, targetPath, value)
1459
+ }
1460
+ }
1461
+ }
1462
+
1463
+ return resolvedInput
1464
+ }
1465
+
1466
+ private async createAttempt(params: {
1467
+ tx: DatabaseTransaction
1468
+ run: PlanRunRecord
1469
+ nodeRun: PlanNodeRunRecord
1470
+ emittedBy: string
1471
+ result: PlanNodeResultSubmission
1472
+ status: 'completed' | 'failed'
1473
+ failureClass: PlanFailureClass | null
1474
+ }) {
1475
+ const attemptId = new RecordId(TABLES.PLAN_NODE_ATTEMPT, Bun.randomUUIDv7())
1476
+ return PlanNodeAttemptSchema.parse(
1477
+ await params.tx
1478
+ .create(attemptId)
1479
+ .content({
1480
+ runId: ensureRecordId(params.run.id, TABLES.PLAN_RUN),
1481
+ nodeRunId: ensureRecordId(params.nodeRun.id, TABLES.PLAN_NODE_RUN),
1482
+ nodeId: params.nodeRun.nodeId,
1483
+ emittedBy: params.emittedBy,
1484
+ status: params.status,
1485
+ ...(params.result.structuredOutput ? { structuredOutput: params.result.structuredOutput } : {}),
1486
+ ...(params.result.notes ? { notes: params.result.notes } : {}),
1487
+ validationIssueIds: [],
1488
+ ...(params.failureClass ? { failureClass: params.failureClass } : {}),
1489
+ })
1490
+ .output('after'),
1491
+ )
1492
+ }
1493
+
1494
+ private async persistValidationIssues(params: {
1495
+ tx: DatabaseTransaction
1496
+ run: PlanRunRecord
1497
+ spec: PlanSpecRecord
1498
+ attemptId: RecordIdInput
1499
+ nodeId: string
1500
+ issues: PlanValidationIssueInput[]
1501
+ }): Promise<PlanValidationIssueRecord[]> {
1502
+ const records: PlanValidationIssueRecord[] = []
1503
+
1504
+ for (const issue of params.issues) {
1505
+ const issueId = new RecordId(TABLES.PLAN_VALIDATION_ISSUE, Bun.randomUUIDv7())
1506
+ const created = await params.tx
1507
+ .create(issueId)
1508
+ .content({
1509
+ planSpecId: ensureRecordId(params.spec.id, TABLES.PLAN_SPEC),
1510
+ runId: ensureRecordId(params.run.id, TABLES.PLAN_RUN),
1511
+ nodeId: issue.nodeId ?? params.nodeId,
1512
+ attemptId: ensureRecordId(params.attemptId, TABLES.PLAN_NODE_ATTEMPT),
1513
+ severity: issue.severity,
1514
+ code: issue.code,
1515
+ message: issue.message,
1516
+ ...(issue.detail ? { detail: issue.detail } : {}),
1517
+ })
1518
+ .output('after')
1519
+
1520
+ records.push(PlanValidationIssueSchema.parse(created))
1521
+ }
1522
+
1523
+ return records
1524
+ }
1525
+
1526
+ private async emitEvent(params: {
1527
+ tx: DatabaseTransaction
1528
+ run: PlanRunRecord
1529
+ spec: PlanSpecRecord
1530
+ eventType: PlanEventType
1531
+ message: string
1532
+ emittedBy: string
1533
+ nodeId?: string
1534
+ attemptId?: RecordIdInput
1535
+ approvalId?: RecordIdInput
1536
+ fromStatus?: string
1537
+ toStatus?: string
1538
+ detail?: Record<string, unknown>
1539
+ }): Promise<PlanEventRecord> {
1540
+ const eventId = new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7())
1541
+ const created = await params.tx
1542
+ .create(eventId)
1543
+ .content({
1544
+ planSpecId: ensureRecordId(params.spec.id, TABLES.PLAN_SPEC),
1545
+ runId: ensureRecordId(params.run.id, TABLES.PLAN_RUN),
1546
+ eventType: params.eventType,
1547
+ message: params.message,
1548
+ emittedBy: params.emittedBy,
1549
+ ...(params.nodeId ? { nodeId: params.nodeId } : {}),
1550
+ ...(params.attemptId ? { attemptId: ensureRecordId(params.attemptId, TABLES.PLAN_NODE_ATTEMPT) } : {}),
1551
+ ...(params.approvalId ? { approvalId: ensureRecordId(params.approvalId, TABLES.PLAN_APPROVAL) } : {}),
1552
+ ...(params.fromStatus ? { fromStatus: params.fromStatus } : {}),
1553
+ ...(params.toStatus ? { toStatus: params.toStatus } : {}),
1554
+ ...(params.detail ? { detail: params.detail } : {}),
1555
+ })
1556
+ .output('after')
1557
+
1558
+ return PlanEventSchema.parse(created)
1559
+ }
1560
+
1561
+ private async replaceRun(tx: DatabaseTransaction, run: PlanRunRecord, patch: PlanRunUpdate): Promise<PlanRunRecord> {
1562
+ return PlanRunSchema.parse(
1563
+ await tx.update(ensureRecordId(run.id, TABLES.PLAN_RUN)).content(toRunData(run, patch)).output('after'),
1564
+ )
1565
+ }
1566
+
1567
+ private async saveCheckpoint(params: {
1568
+ tx: DatabaseTransaction
1569
+ run: PlanRunRecord
1570
+ nodeRuns: PlanNodeRunRecord[]
1571
+ artifacts: Array<{ id: RecordIdInput; nodeId: string }>
1572
+ sequence: number
1573
+ reason: string
1574
+ }) {
1575
+ const checkpoint = await planCheckpointService.createCheckpoint({
1576
+ tx: params.tx,
1577
+ runId: params.run.id,
1578
+ sequence: params.sequence,
1579
+ runStatus: params.run.status,
1580
+ readyNodeIds: params.run.readyNodeIds,
1581
+ activeNodeIds: params.run.currentNodeId ? [params.run.currentNodeId] : [],
1582
+ artifactIds: params.artifacts.map((artifact) => artifact.id),
1583
+ lastCompletedNodeIds: params.nodeRuns
1584
+ .filter((nodeRun) => nodeRun.status === 'completed' || nodeRun.status === 'partial')
1585
+ .map((nodeRun) => nodeRun.nodeId),
1586
+ snapshot: {
1587
+ reason: params.reason,
1588
+ runStatus: params.run.status,
1589
+ currentNodeId: params.run.currentNodeId,
1590
+ waitingNodeId: params.run.waitingNodeId,
1591
+ readyNodeIds: params.run.readyNodeIds,
1592
+ nodeStatuses: Object.fromEntries(params.nodeRuns.map((nodeRun) => [nodeRun.nodeId, nodeRun.status])),
1593
+ },
1594
+ })
1595
+
1596
+ await this.emitEvent({
1597
+ tx: params.tx,
1598
+ run: params.run,
1599
+ spec: await planRunService.getPlanSpecById(params.run.planSpecId),
1600
+ eventType: 'checkpoint-saved',
1601
+ message: `Saved checkpoint ${checkpoint.sequence}.`,
1602
+ detail: { checkpointId: recordIdToString(checkpoint.id, TABLES.PLAN_CHECKPOINT), reason: params.reason },
1603
+ emittedBy: 'system',
1604
+ })
1605
+
1606
+ return checkpoint
1607
+ }
1608
+
1609
+ private async attachCheckpoint(
1610
+ tx: DatabaseTransaction,
1611
+ run: PlanRunRecord,
1612
+ checkpoint: RecordIdInput | { id: RecordIdInput },
1613
+ ) {
1614
+ const checkpointId = checkpoint && typeof checkpoint === 'object' && 'id' in checkpoint ? checkpoint.id : checkpoint
1615
+
1616
+ await tx
1617
+ .update(ensureRecordId(run.id, TABLES.PLAN_RUN))
1618
+ .content(toRunData(run, { lastCheckpointId: checkpointId }))
1619
+ .output('after')
1620
+ }
1621
+ }
1622
+
1623
+ export const planExecutorService = new PlanExecutorService()