@lota-sdk/core 0.1.9 → 0.1.12

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