@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
@@ -1,889 +1,680 @@
1
- import {
2
- ExecutionPlanEventSchema,
3
- ExecutionPlanSchema,
4
- ExecutionPlanTaskResultStatusSchema,
5
- ExecutionPlanTaskSchema,
6
- } from '@lota-sdk/shared/schemas/execution-plan'
7
- import type {
8
- ExecutionPlanEventRecord,
9
- ExecutionPlanRecord,
10
- ExecutionPlanStatus,
11
- ExecutionPlanTaskRecord,
12
- ExecutionPlanTaskResultStatus,
13
- ExecutionPlanTaskStatus,
14
- SerializableExecutionPlan,
15
- SerializableExecutionPlanCarriedTask,
16
- SerializableExecutionPlanEvent,
17
- SerializableExecutionPlanTask,
18
- } from '@lota-sdk/shared/schemas/execution-plan'
19
1
  import type {
2
+ ChatMessage,
20
3
  CreateExecutionPlanArgs,
21
4
  ExecutionPlanToolResultData,
5
+ GetActiveExecutionPlanArgs,
6
+ PlanNodeRunRecord,
7
+ PlanNodeSpecRecord,
8
+ PlanRunRecord,
9
+ PlanSpecRecord,
22
10
  ReplaceExecutionPlanArgs,
23
- SetExecutionTaskStatusArgs,
24
- RestartExecutionTaskArgs,
25
- } from '@lota-sdk/shared/schemas/tools'
26
- import { BoundQuery, RecordId } from 'surrealdb'
27
-
28
- import type { RecordIdInput, RecordIdRef } from '../db/record-id'
11
+ ResumeExecutionPlanRunArgs,
12
+ SerializableExecutionPlan,
13
+ SubmitExecutionNodeResultArgs,
14
+ } from '@lota-sdk/shared'
15
+ import {
16
+ PlanCheckpointSchema,
17
+ PlanEventSchema,
18
+ PlanNodeRunSchema,
19
+ PlanNodeSpecRecordSchema,
20
+ PlanRunSchema,
21
+ PlanSpecSchema,
22
+ } from '@lota-sdk/shared'
23
+ import { RecordId } from 'surrealdb'
24
+
25
+ import type { RecordIdInput } from '../db/record-id'
29
26
  import { ensureRecordId, recordIdToString } from '../db/record-id'
30
27
  import { databaseService } from '../db/service'
28
+ import type { DatabaseTransaction } from '../db/service'
31
29
  import { TABLES } from '../db/tables'
32
- import { toIsoDateTimeString, toOptionalIsoDateTimeString } from '../utils/date-time'
33
-
34
- const ACTIVE_PLAN_STATUSES: ExecutionPlanStatus[] = ['draft', 'executing', 'blocked']
35
- const ACTIVE_PLAN_STATUS_SET = new Set<ExecutionPlanStatus>(ACTIVE_PLAN_STATUSES)
36
- const SUCCESSFUL_RESULT_STATUS_SET = new Set<ExecutionPlanTaskResultStatus>(['success', 'partial'])
37
- const CARRIED_RESULT_STATUS_SET = new Set<ExecutionPlanTaskResultStatus>(['success', 'partial'])
38
- const RESTART_REQUIRED_TASK_STATUS_SET = new Set<ExecutionPlanTaskStatus>(['completed', 'failed', 'skipped'])
39
- const TASK_STATUS_TRANSITIONS: Record<ExecutionPlanTaskStatus, ReadonlySet<ExecutionPlanTaskStatus>> = {
40
- pending: new Set<ExecutionPlanTaskStatus>(['in-progress', 'blocked', 'skipped']),
41
- 'in-progress': new Set<ExecutionPlanTaskStatus>(['completed', 'blocked', 'failed', 'skipped']),
42
- blocked: new Set<ExecutionPlanTaskStatus>(['in-progress', 'completed', 'failed', 'skipped']),
43
- completed: new Set<ExecutionPlanTaskStatus>(),
44
- failed: new Set<ExecutionPlanTaskStatus>(),
45
- skipped: new Set<ExecutionPlanTaskStatus>(),
46
- }
47
-
48
- function normalizeTaskInput(
49
- input: CreateExecutionPlanArgs['tasks'][number]['input'],
50
- ): string | Record<string, unknown> | undefined {
51
- if (input === undefined) return undefined
52
- return typeof input === 'string' ? input : structuredClone(input)
53
- }
54
-
55
- function isExecutionPlanRecord(value: RecordIdInput | ExecutionPlanRecord): value is ExecutionPlanRecord {
56
- return typeof value === 'object' && 'workstreamId' in value
57
- }
58
-
59
- function isExecutionPlanTaskRecord(value: RecordIdInput | ExecutionPlanTaskRecord): value is ExecutionPlanTaskRecord {
60
- return typeof value === 'object' && 'position' in value
61
- }
62
-
63
- function toPlanId(planId: RecordIdInput | ExecutionPlanRecord): RecordIdRef {
64
- return ensureRecordId(isExecutionPlanRecord(planId) ? planId.id : planId, TABLES.PLAN)
65
- }
66
-
67
- function toTaskId(taskId: RecordIdInput | ExecutionPlanTaskRecord): RecordIdRef {
68
- return ensureRecordId(isExecutionPlanTaskRecord(taskId) ? taskId.id : taskId, TABLES.PLAN_TASK)
69
- }
70
-
71
- function toPlanIdString(planId: RecordIdInput | ExecutionPlanRecord): string {
72
- return recordIdToString(toPlanId(planId), TABLES.PLAN)
73
- }
74
-
75
- function toTaskIdString(taskId: RecordIdInput | ExecutionPlanTaskRecord): string {
76
- return recordIdToString(toTaskId(taskId), TABLES.PLAN_TASK)
77
- }
78
-
79
- function setDefinedOptionValue<TValue>(
80
- target: Record<string, unknown>,
81
- key: string,
82
- value: TValue | null | undefined,
83
- mapValue?: (value: TValue) => unknown,
84
- ) {
85
- if (value === undefined || value === null) return
86
- target[key] = mapValue ? mapValue(value) : value
87
- }
88
-
89
- function pushOptionalAssignment<TValue>(
90
- assignments: string[],
91
- bindings: Record<string, unknown>,
92
- key: string,
93
- value: TValue | null | undefined,
94
- mapValue?: (value: TValue) => unknown,
95
- ) {
96
- if (value === undefined) return
97
- if (value === null) {
98
- assignments.push(`${key} = NONE`)
99
- return
100
- }
101
-
102
- assignments.push(`${key} = $${key}`)
103
- bindings[key] = mapValue ? mapValue(value) : value
104
- }
105
-
106
- function serializeTask(task: ExecutionPlanTaskRecord): SerializableExecutionPlanTask {
30
+ import { readApprovalContinuationResponse } from '../runtime/approval-continuation'
31
+ import { extractMessageText } from '../runtime/workstream-chat-helpers'
32
+ import { planBuilderService } from './plan-builder.service'
33
+ import type { CompiledPlanNode } from './plan-compiler.service'
34
+ import { planCompilerService } from './plan-compiler.service'
35
+ import { planExecutorService } from './plan-executor.service'
36
+ import { planRunService } from './plan-run.service'
37
+ import { planValidatorService } from './plan-validator.service'
38
+
39
+ function buildToolResult(params: {
40
+ action: ExecutionPlanToolResultData['action']
41
+ plan: SerializableExecutionPlan | null
42
+ message: string
43
+ changedNodeId?: string
44
+ }): ExecutionPlanToolResultData {
107
45
  return {
108
- id: recordIdToString(task.id, TABLES.PLAN_TASK),
109
- position: task.position,
110
- title: task.title,
111
- rationale: task.rationale,
112
- kind: task.kind,
113
- ownerType: task.ownerType,
114
- ownerRef: task.ownerRef,
115
- status: task.status,
116
- resultStatus: task.resultStatus,
117
- ...(task.input !== undefined ? { input: task.input } : {}),
118
- outputSummary: task.outputSummary,
119
- blockedReason: task.blockedReason,
120
- externalRef: task.externalRef,
121
- retryCount: task.retryCount,
122
- idempotencyKey: task.idempotencyKey,
123
- startedAt: toOptionalIsoDateTimeString(task.startedAt),
124
- completedAt: toOptionalIsoDateTimeString(task.completedAt),
46
+ action: params.action,
47
+ message: params.message,
48
+ ...(params.changedNodeId ? { changedNodeId: params.changedNodeId } : {}),
49
+ ...(params.plan ? { plan: params.plan } : {}),
50
+ hasPlan: params.plan !== null,
51
+ status: params.plan?.status,
125
52
  }
126
53
  }
127
54
 
128
- function serializeEvent(event: ExecutionPlanEventRecord): SerializableExecutionPlanEvent {
129
- return {
130
- id: recordIdToString(event.id, TABLES.PLAN_EVENT),
131
- taskId: event.taskId ? recordIdToString(event.taskId, TABLES.PLAN_TASK) : undefined,
132
- eventType: event.eventType,
133
- fromStatus: event.fromStatus,
134
- toStatus: event.toStatus,
135
- message: event.message,
136
- detail: event.detail,
137
- emittedBy: event.emittedBy,
138
- createdAt: toIsoDateTimeString(event.createdAt),
139
- }
55
+ function aggregateBlockingIssues(issues: Array<{ code: string; message: string }>): string {
56
+ return issues.map((issue) => `${issue.code}: ${issue.message}`).join(' | ')
140
57
  }
141
58
 
142
- function buildProgress(tasks: ExecutionPlanTaskRecord[]) {
143
- const counts = tasks.reduce(
144
- (summary, task) => {
145
- summary[task.status] += 1
146
- return summary
147
- },
148
- { pending: 0, 'in-progress': 0, completed: 0, blocked: 0, failed: 0, skipped: 0 } satisfies Record<
149
- ExecutionPlanTaskStatus,
150
- number
151
- >,
152
- )
153
- const total = tasks.length
154
- const completedWork = counts.completed + counts.skipped
155
-
59
+ function toSpecData(spec: PlanSpecRecord, patch: Partial<PlanSpecRecord> & { replacedSpecId?: RecordIdInput | null }) {
156
60
  return {
157
- total,
158
- pending: counts.pending,
159
- inProgress: counts['in-progress'],
160
- completed: counts.completed,
161
- blocked: counts.blocked,
162
- failed: counts.failed,
163
- skipped: counts.skipped,
164
- completionRatio: total > 0 ? Number((completedWork / total).toFixed(4)) : undefined,
61
+ organizationId: ensureRecordId(spec.organizationId, TABLES.ORGANIZATION),
62
+ workstreamId: ensureRecordId(spec.workstreamId, TABLES.WORKSTREAM),
63
+ title: patch.title ?? spec.title,
64
+ objective: patch.objective ?? spec.objective,
65
+ version: patch.version ?? spec.version,
66
+ status: patch.status ?? spec.status,
67
+ leadAgentId: patch.leadAgentId ?? spec.leadAgentId,
68
+ schemaRegistry: patch.schemaRegistry ? structuredClone(patch.schemaRegistry) : structuredClone(spec.schemaRegistry),
69
+ edges: patch.edges ? [...patch.edges] : [...spec.edges],
70
+ entryNodeIds: patch.entryNodeIds ? [...patch.entryNodeIds] : [...spec.entryNodeIds],
71
+ ...(patch.replacedSpecId
72
+ ? { replacedSpecId: ensureRecordId(patch.replacedSpecId, TABLES.PLAN_SPEC) }
73
+ : spec.replacedSpecId
74
+ ? { replacedSpecId: ensureRecordId(spec.replacedSpecId, TABLES.PLAN_SPEC) }
75
+ : {}),
76
+ ...(patch.compiledAt !== undefined
77
+ ? { compiledAt: patch.compiledAt }
78
+ : spec.compiledAt
79
+ ? { compiledAt: spec.compiledAt }
80
+ : {}),
165
81
  }
166
82
  }
167
83
 
168
- function findCurrentTask(
169
- tasks: ExecutionPlanTaskRecord[],
170
- currentTaskId: string | Record<string, unknown> | null | undefined,
171
- ) {
172
- if (currentTaskId) {
173
- const normalized = recordIdToString(currentTaskId as RecordIdInput, TABLES.PLAN_TASK)
174
- const matched = tasks.find((task) => recordIdToString(task.id, TABLES.PLAN_TASK) === normalized)
175
- if (matched) return matched
176
- }
177
-
178
- return tasks.find((task) => task.status === 'in-progress') ?? tasks.find((task) => task.status === 'pending') ?? null
84
+ type PlanRunUpdate = Omit<
85
+ Partial<PlanRunRecord>,
86
+ 'currentNodeId' | 'waitingNodeId' | 'replacedRunId' | 'lastCheckpointId' | 'startedAt' | 'completedAt'
87
+ > & {
88
+ currentNodeId?: string | null
89
+ waitingNodeId?: string | null
90
+ replacedRunId?: RecordIdInput | null
91
+ lastCheckpointId?: RecordIdInput | null
92
+ startedAt?: Date | null
93
+ completedAt?: Date | null
179
94
  }
180
95
 
181
- function assertTaskStatusTransition(
182
- task: ExecutionPlanTaskRecord,
183
- nextStatus: ExecutionPlanTaskStatus,
184
- taskTitle: string,
185
- ): void {
186
- if (task.status === nextStatus) {
187
- throw new Error(`Execution task "${taskTitle}" is already ${nextStatus}.`)
188
- }
189
-
190
- if (RESTART_REQUIRED_TASK_STATUS_SET.has(task.status)) {
191
- throw new Error(`Use restartExecutionTask before changing execution task "${taskTitle}" from ${task.status}.`)
192
- }
193
-
194
- if (TASK_STATUS_TRANSITIONS[task.status].has(nextStatus)) {
195
- return
96
+ function toRunData(run: PlanRunRecord, patch: PlanRunUpdate) {
97
+ return {
98
+ planSpecId: ensureRecordId(run.planSpecId, TABLES.PLAN_SPEC),
99
+ organizationId: ensureRecordId(run.organizationId, TABLES.ORGANIZATION),
100
+ workstreamId: ensureRecordId(run.workstreamId, TABLES.WORKSTREAM),
101
+ leadAgentId: patch.leadAgentId ?? run.leadAgentId,
102
+ status: patch.status ?? run.status,
103
+ ...(patch.currentNodeId === null
104
+ ? {}
105
+ : patch.currentNodeId !== undefined
106
+ ? { currentNodeId: patch.currentNodeId }
107
+ : run.currentNodeId
108
+ ? { currentNodeId: run.currentNodeId }
109
+ : {}),
110
+ ...(patch.waitingNodeId === null
111
+ ? {}
112
+ : patch.waitingNodeId !== undefined
113
+ ? { waitingNodeId: patch.waitingNodeId }
114
+ : run.waitingNodeId
115
+ ? { waitingNodeId: run.waitingNodeId }
116
+ : {}),
117
+ readyNodeIds: patch.readyNodeIds ? [...patch.readyNodeIds] : [...run.readyNodeIds],
118
+ failureCount: patch.failureCount ?? run.failureCount,
119
+ ...(patch.replacedRunId === null
120
+ ? {}
121
+ : patch.replacedRunId
122
+ ? { replacedRunId: ensureRecordId(patch.replacedRunId, TABLES.PLAN_RUN) }
123
+ : run.replacedRunId
124
+ ? { replacedRunId: ensureRecordId(run.replacedRunId, TABLES.PLAN_RUN) }
125
+ : {}),
126
+ ...(patch.lastCheckpointId === null
127
+ ? {}
128
+ : patch.lastCheckpointId
129
+ ? { lastCheckpointId: ensureRecordId(patch.lastCheckpointId, TABLES.PLAN_CHECKPOINT) }
130
+ : run.lastCheckpointId
131
+ ? { lastCheckpointId: ensureRecordId(run.lastCheckpointId, TABLES.PLAN_CHECKPOINT) }
132
+ : {}),
133
+ ...(patch.startedAt === null
134
+ ? {}
135
+ : patch.startedAt !== undefined
136
+ ? { startedAt: patch.startedAt }
137
+ : run.startedAt
138
+ ? { startedAt: run.startedAt }
139
+ : {}),
140
+ ...(patch.completedAt === null
141
+ ? {}
142
+ : patch.completedAt !== undefined
143
+ ? { completedAt: patch.completedAt }
144
+ : run.completedAt
145
+ ? { completedAt: run.completedAt }
146
+ : {}),
196
147
  }
197
-
198
- throw new Error(`Cannot change execution task "${taskTitle}" from ${task.status} to ${nextStatus}.`)
199
148
  }
200
149
 
201
- class ExecutionPlanService {
202
- private async getTaskById(taskId: RecordIdInput): Promise<ExecutionPlanTaskRecord> {
203
- const recordId = ensureRecordId(taskId, TABLES.PLAN_TASK)
204
- const row = await databaseService.findOne(TABLES.PLAN_TASK, { id: recordId }, ExecutionPlanTaskSchema)
205
- if (!row) {
206
- throw new Error(`Execution plan task not found: ${recordId.toString()}`)
207
- }
208
- return row
209
- }
210
-
211
- private async listPlanTasks(planId: RecordIdInput): Promise<ExecutionPlanTaskRecord[]> {
212
- return await databaseService.findMany(
213
- TABLES.PLAN_TASK,
214
- { planId: ensureRecordId(planId, TABLES.PLAN) },
215
- ExecutionPlanTaskSchema,
216
- { orderBy: 'position', orderDir: 'ASC' },
217
- )
218
- }
219
-
220
- private async listPlanEvents(planId: RecordIdInput, limit = 20): Promise<ExecutionPlanEventRecord[]> {
221
- return (
222
- await databaseService.findMany(
223
- TABLES.PLAN_EVENT,
224
- { planId: ensureRecordId(planId, TABLES.PLAN) },
225
- ExecutionPlanEventSchema,
226
- { orderBy: 'createdAt', orderDir: 'DESC', limit },
227
- )
228
- ).reverse()
229
- }
230
-
231
- private async emitEvent(params: {
232
- planId: RecordIdInput
233
- taskId?: RecordIdInput | null
234
- eventType: ExecutionPlanEventRecord['eventType']
235
- fromStatus?: string | null
236
- toStatus?: string | null
237
- message: string
238
- detail?: Record<string, unknown> | null
239
- emittedBy: string
240
- }): Promise<void> {
241
- const eventData: Record<string, unknown> = {
242
- planId: ensureRecordId(params.planId, TABLES.PLAN),
243
- eventType: params.eventType,
244
- message: params.message,
245
- emittedBy: params.emittedBy,
246
- }
247
- setDefinedOptionValue(eventData, 'taskId', params.taskId, (taskId) => ensureRecordId(taskId, TABLES.PLAN_TASK))
248
- setDefinedOptionValue(eventData, 'fromStatus', params.fromStatus)
249
- setDefinedOptionValue(eventData, 'toStatus', params.toStatus)
250
- setDefinedOptionValue(eventData, 'detail', params.detail)
251
-
252
- await databaseService.create(TABLES.PLAN_EVENT, eventData, ExecutionPlanEventSchema)
253
- }
254
-
255
- private async updatePlanStatus(params: {
256
- plan: ExecutionPlanRecord
257
- status: ExecutionPlanStatus
258
- currentTaskId?: RecordIdInput | null
259
- restartFromTaskId?: RecordIdInput | null
260
- failureCount?: number
261
- startedAt?: Date | null
262
- completedAt?: Date | null
263
- emittedBy: string
264
- message?: string
265
- detail?: Record<string, unknown>
266
- }): Promise<ExecutionPlanRecord> {
267
- const bindings: Record<string, unknown> = { status: params.status }
268
- const assignments = ['status = $status']
269
- pushOptionalAssignment(assignments, bindings, 'currentTaskId', params.currentTaskId, (taskId) =>
270
- ensureRecordId(taskId, TABLES.PLAN_TASK),
271
- )
272
- pushOptionalAssignment(assignments, bindings, 'restartFromTaskId', params.restartFromTaskId, (taskId) =>
273
- ensureRecordId(taskId, TABLES.PLAN_TASK),
274
- )
275
- if (params.failureCount !== undefined) {
276
- assignments.push('failureCount = $failureCount')
277
- bindings.failureCount = params.failureCount
278
- }
279
- pushOptionalAssignment(assignments, bindings, 'startedAt', params.startedAt)
280
- pushOptionalAssignment(assignments, bindings, 'completedAt', params.completedAt)
281
-
282
- const rows = await databaseService.query<unknown>(
283
- new BoundQuery(`UPDATE ONLY ${toPlanIdString(params.plan)} SET ${assignments.join(', ')} RETURN AFTER`, bindings),
284
- )
285
- const next = rows.at(0) ? ExecutionPlanSchema.parse(rows[0]) : null
286
-
287
- if (!next) {
288
- throw new Error(`Failed to update execution plan ${recordIdToString(params.plan.id, TABLES.PLAN)}`)
150
+ function buildApprovalResponseFromMessages(
151
+ messages: ChatMessage[],
152
+ ): { approvalId: string; response: Record<string, unknown> } | null {
153
+ for (const message of [...messages].reverse()) {
154
+ if (message.role !== 'assistant') continue
155
+ const approvalResponse = readApprovalContinuationResponse(message)
156
+ if (!approvalResponse) continue
157
+
158
+ const response: Record<string, unknown> = {
159
+ approved: approvalResponse.approved,
160
+ requiredEdits: [...approvalResponse.requiredEdits],
289
161
  }
290
-
291
- if (params.plan.status !== next.status) {
292
- await this.emitEvent({
293
- planId: next.id,
294
- eventType: 'plan-status-changed',
295
- fromStatus: params.plan.status,
296
- toStatus: next.status,
297
- message: params.message ?? `Plan status changed from ${params.plan.status} to ${next.status}.`,
298
- detail: params.detail,
299
- emittedBy: params.emittedBy,
300
- })
301
- }
302
-
303
- return next
304
- }
305
-
306
- private async updateTaskRecord(params: {
307
- task: ExecutionPlanTaskRecord
308
- status: ExecutionPlanTaskStatus
309
- resultStatus: ExecutionPlanTaskResultStatus
310
- outputSummary?: string | null
311
- blockedReason?: string | null
312
- externalRef?: string | null
313
- startedAt?: Date | null
314
- completedAt?: Date | null
315
- }): Promise<ExecutionPlanTaskRecord> {
316
- const bindings: Record<string, unknown> = { status: params.status, resultStatus: params.resultStatus }
317
- const assignments = ['status = $status', 'resultStatus = $resultStatus']
318
- pushOptionalAssignment(assignments, bindings, 'outputSummary', params.outputSummary)
319
- pushOptionalAssignment(assignments, bindings, 'blockedReason', params.blockedReason)
320
- pushOptionalAssignment(assignments, bindings, 'externalRef', params.externalRef)
321
- pushOptionalAssignment(assignments, bindings, 'startedAt', params.startedAt)
322
- pushOptionalAssignment(assignments, bindings, 'completedAt', params.completedAt)
323
-
324
- const rows = await databaseService.query<unknown>(
325
- new BoundQuery(`UPDATE ONLY ${toTaskIdString(params.task)} SET ${assignments.join(', ')} RETURN AFTER`, bindings),
326
- )
327
- const next = rows.at(0) ? ExecutionPlanTaskSchema.parse(rows[0]) : null
328
- if (!next) {
329
- throw new Error(`Failed to update execution task ${recordIdToString(params.task.id, TABLES.PLAN_TASK)}`)
330
- }
331
-
332
- return next
333
- }
334
-
335
- private async getActivePlanRecord(workstreamId: RecordIdInput): Promise<ExecutionPlanRecord | null> {
336
- const plans = await databaseService.findMany(
337
- TABLES.PLAN,
338
- { workstreamId: ensureRecordId(workstreamId, TABLES.WORKSTREAM) },
339
- ExecutionPlanSchema,
340
- { orderBy: 'updatedAt', orderDir: 'DESC' },
341
- )
342
-
343
- return plans.find((plan) => ACTIVE_PLAN_STATUS_SET.has(plan.status)) ?? null
344
- }
345
-
346
- private async collectCarriedTasks(plan: ExecutionPlanRecord): Promise<SerializableExecutionPlanCarriedTask[]> {
347
- const carried: SerializableExecutionPlanCarriedTask[] = []
348
- let currentReplacedPlanId = plan.replacedPlanId ? ensureRecordId(plan.replacedPlanId, TABLES.PLAN) : null
349
- let depth = 0
350
-
351
- while (currentReplacedPlanId && depth < 5) {
352
- const previousPlan = await databaseService.findOne(
353
- TABLES.PLAN,
354
- { id: currentReplacedPlanId },
355
- ExecutionPlanSchema,
356
- )
357
- if (!previousPlan) break
358
-
359
- const previousTasks = await this.listPlanTasks(previousPlan.id)
360
- const currentPlanId = recordIdToString(previousPlan.id, TABLES.PLAN)
361
- carried.unshift(
362
- ...previousTasks
363
- .filter(
364
- (task) =>
365
- CARRIED_RESULT_STATUS_SET.has(task.resultStatus) &&
366
- typeof task.outputSummary === 'string' &&
367
- task.outputSummary.trim().length > 0,
368
- )
369
- .map((task) => ({
370
- planId: currentPlanId,
371
- taskId: recordIdToString(task.id, TABLES.PLAN_TASK),
372
- title: task.title,
373
- resultStatus: task.resultStatus,
374
- outputSummary: task.outputSummary ?? '',
375
- })),
376
- )
377
-
378
- currentReplacedPlanId = previousPlan.replacedPlanId
379
- ? ensureRecordId(previousPlan.replacedPlanId, TABLES.PLAN)
380
- : null
381
- depth += 1
162
+ if (approvalResponse.comments) {
163
+ response.comments = approvalResponse.comments
382
164
  }
383
165
 
384
- return carried
385
- }
386
-
387
- private async toSerializablePlan(
388
- plan: ExecutionPlanRecord,
389
- options?: { includeEvents?: boolean },
390
- ): Promise<SerializableExecutionPlan> {
391
- const tasks = await this.listPlanTasks(plan.id)
392
- const recentEvents = options?.includeEvents ? await this.listPlanEvents(plan.id, 20) : []
393
- const carriedTasks = await this.collectCarriedTasks(plan)
394
- const currentTask = findCurrentTask(tasks, plan.currentTaskId)
395
-
396
- return {
397
- planId: recordIdToString(plan.id, TABLES.PLAN),
398
- workstreamId: recordIdToString(plan.workstreamId, TABLES.WORKSTREAM),
399
- organizationId: recordIdToString(plan.organizationId, TABLES.ORGANIZATION),
400
- title: plan.title,
401
- objective: plan.objective,
402
- status: plan.status,
403
- leadAgentId: plan.leadAgentId,
404
- currentTaskId: currentTask ? recordIdToString(currentTask.id, TABLES.PLAN_TASK) : undefined,
405
- restartFromTaskId: plan.restartFromTaskId
406
- ? recordIdToString(plan.restartFromTaskId, TABLES.PLAN_TASK)
407
- : undefined,
408
- replacedPlanId: plan.replacedPlanId ? recordIdToString(plan.replacedPlanId, TABLES.PLAN) : undefined,
409
- failureCount: plan.failureCount,
410
- startedAt: toOptionalIsoDateTimeString(plan.startedAt),
411
- completedAt: toOptionalIsoDateTimeString(plan.completedAt),
412
- progress: buildProgress(tasks),
413
- tasks: tasks.map(serializeTask),
414
- recentEvents: recentEvents.map(serializeEvent),
415
- carriedTasks,
416
- }
166
+ return { approvalId: approvalResponse.approvalId, response }
417
167
  }
418
168
 
419
- private buildToolResult(params: {
420
- action: ExecutionPlanToolResultData['action']
421
- plan: SerializableExecutionPlan | null
422
- message?: string
423
- changedTaskId?: string
424
- }): ExecutionPlanToolResultData {
425
- return {
426
- action: params.action,
427
- ...(params.message ? { message: params.message } : {}),
428
- ...(params.changedTaskId !== undefined ? { changedTaskId: params.changedTaskId } : {}),
429
- ...(params.plan ? { plan: params.plan } : {}),
430
- hasPlan: params.plan !== null,
431
- status: params.plan?.status,
432
- }
433
- }
169
+ return null
170
+ }
434
171
 
172
+ class ExecutionPlanService {
435
173
  async hasActivePlan(workstreamId: RecordIdInput): Promise<boolean> {
436
- return (await this.getActivePlanRecord(workstreamId)) !== null
174
+ return (await planRunService.getActiveRunRecord(workstreamId)) !== null
437
175
  }
438
176
 
439
177
  async getActivePlanForWorkstream(
440
178
  workstreamId: RecordIdInput,
441
- options?: { includeEvents?: boolean },
179
+ options?: Partial<GetActiveExecutionPlanArgs>,
442
180
  ): Promise<SerializableExecutionPlan | null> {
443
- const plan = await this.getActivePlanRecord(workstreamId)
444
- if (!plan) return null
445
- return await this.toSerializablePlan(plan, options)
181
+ const run = await planRunService.getActiveRunRecord(workstreamId)
182
+ if (!run) return null
183
+
184
+ return await planRunService.toSerializablePlan(run, {
185
+ includeEvents: options?.includeEvents,
186
+ includeArtifacts: options?.includeArtifacts,
187
+ includeApprovals: options?.includeApprovals,
188
+ includeCheckpoints: options?.includeCheckpoints,
189
+ includeValidationIssues: options?.includeValidationIssues,
190
+ })
191
+ }
192
+
193
+ async getActivePlanToolResult(params: {
194
+ workstreamId: RecordIdInput
195
+ includeEvents?: boolean
196
+ includeArtifacts?: boolean
197
+ includeApprovals?: boolean
198
+ includeCheckpoints?: boolean
199
+ includeValidationIssues?: boolean
200
+ }): Promise<ExecutionPlanToolResultData> {
201
+ const plan = await this.getActivePlanForWorkstream(params.workstreamId, params)
202
+ return buildToolResult({
203
+ action: plan ? 'loaded' : 'none',
204
+ plan,
205
+ message: plan ? `Loaded execution run "${plan.title}".` : 'No active execution run.',
206
+ })
446
207
  }
447
208
 
448
209
  async createPlan(params: {
449
210
  organizationId: RecordIdInput
450
211
  workstreamId: RecordIdInput
451
- leadAgentId: ExecutionPlanRecord['leadAgentId']
212
+ leadAgentId: string
452
213
  input: CreateExecutionPlanArgs
453
214
  }): Promise<ExecutionPlanToolResultData> {
454
- const activePlan = await this.getActivePlanRecord(params.workstreamId)
455
- if (activePlan) {
456
- throw new Error('An active execution plan already exists for this workstream. Replace it instead.')
215
+ const activeRun = await planRunService.getActiveRunRecord(params.workstreamId)
216
+ if (activeRun) {
217
+ throw new Error('An active execution run already exists for this workstream. Replace it instead.')
457
218
  }
458
219
 
459
- const planId = new RecordId(TABLES.PLAN, Bun.randomUUIDv7())
460
- const taskIds = params.input.tasks.map(() => new RecordId(TABLES.PLAN_TASK, Bun.randomUUIDv7()))
461
- const organizationId = ensureRecordId(params.organizationId, TABLES.ORGANIZATION)
462
- const workstreamId = ensureRecordId(params.workstreamId, TABLES.WORKSTREAM)
463
- const planData: Record<string, unknown> = {
464
- organizationId,
465
- workstreamId,
466
- title: params.input.title,
467
- objective: params.input.objective,
468
- status: 'draft',
469
- leadAgentId: params.leadAgentId,
470
- currentTaskId: taskIds[0],
471
- failureCount: 0,
220
+ const preparedDraft = planBuilderService.prepareDraft(params.input)
221
+ const validation = planValidatorService.validateDraft(preparedDraft)
222
+ if (validation.blocking.length > 0) {
223
+ throw new Error(`Plan draft failed validation: ${aggregateBlockingIssues(validation.blocking)}`)
472
224
  }
225
+ const compiled = planCompilerService.compile(preparedDraft)
226
+
227
+ const specId = new RecordId(TABLES.PLAN_SPEC, Bun.randomUUIDv7())
228
+ const runId = new RecordId(TABLES.PLAN_RUN, Bun.randomUUIDv7())
229
+
230
+ await databaseService.withTransaction(async (tx) => {
231
+ const spec = PlanSpecSchema.parse(
232
+ await tx
233
+ .create(specId)
234
+ .content({
235
+ organizationId: ensureRecordId(params.organizationId, TABLES.ORGANIZATION),
236
+ workstreamId: ensureRecordId(params.workstreamId, TABLES.WORKSTREAM),
237
+ title: compiled.draft.title,
238
+ objective: compiled.draft.objective,
239
+ version: 1,
240
+ status: 'compiled',
241
+ leadAgentId: params.leadAgentId,
242
+ schemaRegistry: structuredClone(compiled.draft.schemas),
243
+ edges: [...compiled.draft.edges],
244
+ entryNodeIds: [...(compiled.draft.entryNodeIds ?? [])],
245
+ compiledAt: new Date(),
246
+ })
247
+ .output('after'),
248
+ )
473
249
 
474
- const plan = await databaseService.createWithId(TABLES.PLAN, planId, planData, ExecutionPlanSchema)
475
-
476
- for (const [index, task] of params.input.tasks.entries()) {
477
- const taskData: Record<string, unknown> = {
478
- planId,
479
- organizationId,
480
- workstreamId,
481
- position: index,
482
- title: task.title,
483
- rationale: task.rationale,
484
- kind: task.kind,
485
- ownerType: task.ownerType,
486
- ownerRef: task.ownerRef,
487
- status: 'pending',
488
- resultStatus: 'not-started',
489
- retryCount: 0,
490
- idempotencyKey: `${planId.toString()}:${index}`,
491
- }
492
- setDefinedOptionValue(taskData, 'input', task.input, normalizeTaskInput)
493
-
494
- await databaseService.createWithId(TABLES.PLAN_TASK, taskIds[index], taskData, ExecutionPlanTaskSchema)
495
- }
250
+ const nodeSpecs = await this.createNodeSpecs(tx, spec.id, compiled.nodes)
251
+ const run = PlanRunSchema.parse(
252
+ await tx
253
+ .create(runId)
254
+ .content({
255
+ planSpecId: ensureRecordId(spec.id, TABLES.PLAN_SPEC),
256
+ organizationId: ensureRecordId(params.organizationId, TABLES.ORGANIZATION),
257
+ workstreamId: ensureRecordId(params.workstreamId, TABLES.WORKSTREAM),
258
+ leadAgentId: params.leadAgentId,
259
+ status: 'running',
260
+ readyNodeIds: [],
261
+ failureCount: 0,
262
+ startedAt: new Date(),
263
+ })
264
+ .output('after'),
265
+ )
496
266
 
497
- await this.emitEvent({
498
- planId: plan.id,
499
- eventType: 'plan-created',
500
- message: `Created execution plan "${plan.title}" with ${params.input.tasks.length} tasks.`,
501
- detail: { title: plan.title, objective: plan.objective, taskCount: params.input.tasks.length },
502
- emittedBy: params.leadAgentId,
267
+ const nodeRuns = await this.createNodeRuns(tx, run.id, spec.id, nodeSpecs)
268
+ const synced = await planExecutorService.syncRunGraph({
269
+ tx,
270
+ run,
271
+ spec,
272
+ nodeSpecs,
273
+ nodeRuns,
274
+ artifacts: [],
275
+ emittedBy: params.leadAgentId,
276
+ })
277
+
278
+ const event = await tx
279
+ .create(new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7()))
280
+ .content({
281
+ planSpecId: ensureRecordId(spec.id, TABLES.PLAN_SPEC),
282
+ runId: ensureRecordId(synced.run.id, TABLES.PLAN_RUN),
283
+ eventType: 'plan-created',
284
+ message: `Created execution plan "${spec.title}".`,
285
+ emittedBy: params.leadAgentId,
286
+ detail: { title: spec.title, objective: spec.objective, nodeCount: nodeSpecs.length },
287
+ })
288
+ .output('after')
289
+ PlanEventSchema.parse(event)
290
+
291
+ const checkpoint = PlanCheckpointSchema.parse(
292
+ await tx
293
+ .create(new RecordId(TABLES.PLAN_CHECKPOINT, Bun.randomUUIDv7()))
294
+ .content({
295
+ runId: ensureRecordId(synced.run.id, TABLES.PLAN_RUN),
296
+ sequence: 1,
297
+ runStatus: synced.run.status,
298
+ readyNodeIds: [...synced.run.readyNodeIds],
299
+ activeNodeIds: synced.run.currentNodeId ? [synced.run.currentNodeId] : [],
300
+ artifactIds: [],
301
+ lastCompletedNodeIds: synced.nodeRuns
302
+ .filter((nodeRun) => nodeRun.status === 'completed' || nodeRun.status === 'partial')
303
+ .map((nodeRun) => nodeRun.nodeId),
304
+ snapshot: {
305
+ reason: 'plan-created',
306
+ currentNodeId: synced.run.currentNodeId,
307
+ waitingNodeId: synced.run.waitingNodeId,
308
+ readyNodeIds: synced.run.readyNodeIds,
309
+ },
310
+ })
311
+ .output('after'),
312
+ )
313
+
314
+ const updatedRun = PlanRunSchema.parse(
315
+ await tx
316
+ .update(ensureRecordId(synced.run.id, TABLES.PLAN_RUN))
317
+ .content(toRunData(synced.run, { lastCheckpointId: checkpoint.id }))
318
+ .output('after'),
319
+ )
320
+
321
+ const checkpointEvent = await tx
322
+ .create(new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7()))
323
+ .content({
324
+ planSpecId: ensureRecordId(spec.id, TABLES.PLAN_SPEC),
325
+ runId: ensureRecordId(updatedRun.id, TABLES.PLAN_RUN),
326
+ eventType: 'checkpoint-saved',
327
+ message: 'Saved checkpoint 1.',
328
+ emittedBy: 'system',
329
+ detail: { checkpointId: recordIdToString(checkpoint.id, TABLES.PLAN_CHECKPOINT), reason: 'plan-created' },
330
+ })
331
+ .output('after')
332
+ PlanEventSchema.parse(checkpointEvent)
503
333
  })
504
334
 
505
- const serializable = await this.toSerializablePlan(plan, { includeEvents: true })
506
- return this.buildToolResult({
507
- action: 'created',
508
- plan: serializable,
509
- message: `Created execution plan "${plan.title}".`,
335
+ const plan = await planRunService.toSerializablePlan(await planRunService.getRunById(runId), {
336
+ includeEvents: true,
337
+ includeArtifacts: true,
338
+ includeApprovals: true,
339
+ includeCheckpoints: true,
340
+ includeValidationIssues: true,
510
341
  })
342
+
343
+ return buildToolResult({ action: 'created', plan, message: `Created execution plan "${plan.title}".` })
511
344
  }
512
345
 
513
346
  async replacePlan(params: {
514
347
  workstreamId: RecordIdInput
515
348
  organizationId: RecordIdInput
516
- leadAgentId: ExecutionPlanRecord['leadAgentId']
349
+ leadAgentId: string
517
350
  input: ReplaceExecutionPlanArgs
518
351
  }): Promise<ExecutionPlanToolResultData> {
519
- const activePlan = await this.getActivePlanRecord(params.workstreamId)
520
- if (!activePlan) {
521
- throw new Error('No active execution plan exists for this workstream.')
352
+ const activeRun = await planRunService.getActiveRunRecord(params.workstreamId)
353
+ if (!activeRun) {
354
+ throw new Error('No active execution run exists for this workstream.')
522
355
  }
523
- if (recordIdToString(activePlan.id, TABLES.PLAN) !== params.input.planId) {
524
- throw new Error('Only the active execution plan can be replaced.')
356
+ if (recordIdToString(activeRun.id, TABLES.PLAN_RUN) !== params.input.runId) {
357
+ throw new Error('Only the active execution run can be replaced.')
525
358
  }
526
359
 
527
- const oldPlan = await this.updatePlanStatus({
528
- plan: activePlan,
529
- status: 'aborted',
530
- currentTaskId: activePlan.currentTaskId,
531
- restartFromTaskId: activePlan.restartFromTaskId,
532
- emittedBy: params.leadAgentId,
533
- message: `Plan "${activePlan.title}" was replaced.`,
534
- detail: { reason: params.input.reason },
535
- })
536
-
537
- const planId = new RecordId(TABLES.PLAN, Bun.randomUUIDv7())
538
- const taskIds = params.input.tasks.map(() => new RecordId(TABLES.PLAN_TASK, Bun.randomUUIDv7()))
539
- const organizationId = ensureRecordId(params.organizationId, TABLES.ORGANIZATION)
540
- const workstreamId = ensureRecordId(params.workstreamId, TABLES.WORKSTREAM)
541
- const nextPlanData: Record<string, unknown> = {
542
- organizationId,
543
- workstreamId,
360
+ const activeSpec = await planRunService.getPlanSpecById(activeRun.planSpecId)
361
+ const preparedDraft = planBuilderService.prepareDraft({
544
362
  title: params.input.title,
545
363
  objective: params.input.objective,
546
- status: 'draft',
547
- leadAgentId: params.leadAgentId,
548
- currentTaskId: taskIds[0],
549
- replacedPlanId: ensureRecordId(oldPlan.id, TABLES.PLAN),
550
- failureCount: 0,
364
+ nodes: params.input.nodes,
365
+ edges: params.input.edges,
366
+ entryNodeIds: params.input.entryNodeIds,
367
+ schemas: params.input.schemas,
368
+ })
369
+ const validation = planValidatorService.validateDraft(preparedDraft)
370
+ if (validation.blocking.length > 0) {
371
+ throw new Error(`Plan draft failed validation: ${aggregateBlockingIssues(validation.blocking)}`)
551
372
  }
373
+ const compiled = planCompilerService.compile(preparedDraft)
552
374
 
553
- const nextPlan = await databaseService.createWithId(TABLES.PLAN, planId, nextPlanData, ExecutionPlanSchema)
554
-
555
- for (const [index, task] of params.input.tasks.entries()) {
556
- const taskData: Record<string, unknown> = {
557
- planId,
558
- organizationId,
559
- workstreamId,
560
- position: index,
561
- title: task.title,
562
- rationale: task.rationale,
563
- kind: task.kind,
564
- ownerType: task.ownerType,
565
- ownerRef: task.ownerRef,
566
- status: 'pending',
567
- resultStatus: 'not-started',
568
- retryCount: 0,
569
- idempotencyKey: `${planId.toString()}:${index}`,
570
- }
571
- setDefinedOptionValue(taskData, 'input', task.input, normalizeTaskInput)
572
-
573
- await databaseService.createWithId(TABLES.PLAN_TASK, taskIds[index], taskData, ExecutionPlanTaskSchema)
574
- }
375
+ const specId = new RecordId(TABLES.PLAN_SPEC, Bun.randomUUIDv7())
376
+ const runId = new RecordId(TABLES.PLAN_RUN, Bun.randomUUIDv7())
575
377
 
576
- await this.emitEvent({
577
- planId: oldPlan.id,
578
- eventType: 'replan',
579
- message: `Created replacement plan "${nextPlan.title}".`,
580
- detail: {
581
- reason: params.input.reason,
582
- oldPlanId: recordIdToString(oldPlan.id, TABLES.PLAN),
583
- newPlanId: recordIdToString(nextPlan.id, TABLES.PLAN),
584
- },
585
- emittedBy: params.leadAgentId,
586
- })
587
- await this.emitEvent({
588
- planId: nextPlan.id,
589
- eventType: 'plan-created',
590
- message: `Created execution plan "${nextPlan.title}" with ${params.input.tasks.length} tasks.`,
591
- detail: {
592
- title: nextPlan.title,
593
- objective: nextPlan.objective,
594
- taskCount: params.input.tasks.length,
595
- replacedPlanId: recordIdToString(oldPlan.id, TABLES.PLAN),
596
- },
597
- emittedBy: params.leadAgentId,
598
- })
599
- await this.emitEvent({
600
- planId: nextPlan.id,
601
- eventType: 'plan-replaced',
602
- message: `Plan "${nextPlan.title}" replaces "${oldPlan.title}".`,
603
- detail: { reason: params.input.reason, replacedPlanId: recordIdToString(oldPlan.id, TABLES.PLAN) },
604
- emittedBy: params.leadAgentId,
378
+ await databaseService.withTransaction(async (tx) => {
379
+ const supersededSpec = PlanSpecSchema.parse(
380
+ await tx
381
+ .update(ensureRecordId(activeSpec.id, TABLES.PLAN_SPEC))
382
+ .content(toSpecData(activeSpec, { status: 'superseded' }))
383
+ .output('after'),
384
+ )
385
+
386
+ const abortedRun = PlanRunSchema.parse(
387
+ await tx
388
+ .update(ensureRecordId(activeRun.id, TABLES.PLAN_RUN))
389
+ .content(
390
+ toRunData(activeRun, {
391
+ status: 'aborted',
392
+ currentNodeId: null,
393
+ waitingNodeId: null,
394
+ readyNodeIds: [],
395
+ completedAt: new Date(),
396
+ }),
397
+ )
398
+ .output('after'),
399
+ )
400
+
401
+ const spec = PlanSpecSchema.parse(
402
+ await tx
403
+ .create(specId)
404
+ .content({
405
+ organizationId: ensureRecordId(params.organizationId, TABLES.ORGANIZATION),
406
+ workstreamId: ensureRecordId(params.workstreamId, TABLES.WORKSTREAM),
407
+ title: compiled.draft.title,
408
+ objective: compiled.draft.objective,
409
+ version: supersededSpec.version + 1,
410
+ status: 'compiled',
411
+ leadAgentId: params.leadAgentId,
412
+ schemaRegistry: structuredClone(compiled.draft.schemas),
413
+ edges: [...compiled.draft.edges],
414
+ entryNodeIds: [...(compiled.draft.entryNodeIds ?? [])],
415
+ replacedSpecId: ensureRecordId(supersededSpec.id, TABLES.PLAN_SPEC),
416
+ compiledAt: new Date(),
417
+ })
418
+ .output('after'),
419
+ )
420
+
421
+ const nodeSpecs = await this.createNodeSpecs(tx, spec.id, compiled.nodes)
422
+ const run = PlanRunSchema.parse(
423
+ await tx
424
+ .create(runId)
425
+ .content({
426
+ planSpecId: ensureRecordId(spec.id, TABLES.PLAN_SPEC),
427
+ organizationId: ensureRecordId(params.organizationId, TABLES.ORGANIZATION),
428
+ workstreamId: ensureRecordId(params.workstreamId, TABLES.WORKSTREAM),
429
+ leadAgentId: params.leadAgentId,
430
+ status: 'running',
431
+ readyNodeIds: [],
432
+ failureCount: 0,
433
+ replacedRunId: ensureRecordId(abortedRun.id, TABLES.PLAN_RUN),
434
+ startedAt: new Date(),
435
+ })
436
+ .output('after'),
437
+ )
438
+
439
+ const nodeRuns = await this.createNodeRuns(tx, run.id, spec.id, nodeSpecs)
440
+ const synced = await planExecutorService.syncRunGraph({
441
+ tx,
442
+ run,
443
+ spec,
444
+ nodeSpecs,
445
+ nodeRuns,
446
+ artifacts: [],
447
+ emittedBy: params.leadAgentId,
448
+ })
449
+
450
+ const replaceEvent = await tx
451
+ .create(new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7()))
452
+ .content({
453
+ planSpecId: ensureRecordId(spec.id, TABLES.PLAN_SPEC),
454
+ runId: ensureRecordId(synced.run.id, TABLES.PLAN_RUN),
455
+ eventType: 'plan-replaced',
456
+ message: `Replaced execution plan "${activeSpec.title}" with "${spec.title}".`,
457
+ emittedBy: params.leadAgentId,
458
+ detail: { reason: params.input.reason, replacedRunId: recordIdToString(abortedRun.id, TABLES.PLAN_RUN) },
459
+ })
460
+ .output('after')
461
+ PlanEventSchema.parse(replaceEvent)
462
+
463
+ const checkpoint = PlanCheckpointSchema.parse(
464
+ await tx
465
+ .create(new RecordId(TABLES.PLAN_CHECKPOINT, Bun.randomUUIDv7()))
466
+ .content({
467
+ runId: ensureRecordId(synced.run.id, TABLES.PLAN_RUN),
468
+ sequence: 1,
469
+ runStatus: synced.run.status,
470
+ readyNodeIds: [...synced.run.readyNodeIds],
471
+ activeNodeIds: synced.run.currentNodeId ? [synced.run.currentNodeId] : [],
472
+ artifactIds: [],
473
+ lastCompletedNodeIds: synced.nodeRuns
474
+ .filter((nodeRun) => nodeRun.status === 'completed' || nodeRun.status === 'partial')
475
+ .map((nodeRun) => nodeRun.nodeId),
476
+ snapshot: {
477
+ reason: 'plan-replaced',
478
+ currentNodeId: synced.run.currentNodeId,
479
+ waitingNodeId: synced.run.waitingNodeId,
480
+ readyNodeIds: synced.run.readyNodeIds,
481
+ },
482
+ })
483
+ .output('after'),
484
+ )
485
+
486
+ const updatedRun = PlanRunSchema.parse(
487
+ await tx
488
+ .update(ensureRecordId(synced.run.id, TABLES.PLAN_RUN))
489
+ .content(toRunData(synced.run, { lastCheckpointId: checkpoint.id }))
490
+ .output('after'),
491
+ )
492
+
493
+ const checkpointEvent = await tx
494
+ .create(new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7()))
495
+ .content({
496
+ planSpecId: ensureRecordId(spec.id, TABLES.PLAN_SPEC),
497
+ runId: ensureRecordId(updatedRun.id, TABLES.PLAN_RUN),
498
+ eventType: 'checkpoint-saved',
499
+ message: 'Saved checkpoint 1.',
500
+ emittedBy: 'system',
501
+ detail: { checkpointId: recordIdToString(checkpoint.id, TABLES.PLAN_CHECKPOINT), reason: 'plan-replaced' },
502
+ })
503
+ .output('after')
504
+ PlanEventSchema.parse(checkpointEvent)
605
505
  })
606
506
 
607
- const serializable = await this.toSerializablePlan(nextPlan, { includeEvents: true })
608
- return this.buildToolResult({
609
- action: 'replaced',
610
- plan: serializable,
611
- message: `Replaced execution plan with "${nextPlan.title}".`,
507
+ const plan = await planRunService.toSerializablePlan(await planRunService.getRunById(runId), {
508
+ includeEvents: true,
509
+ includeArtifacts: true,
510
+ includeApprovals: true,
511
+ includeCheckpoints: true,
512
+ includeValidationIssues: true,
612
513
  })
514
+
515
+ return buildToolResult({ action: 'replaced', plan, message: `Replaced execution plan with "${plan.title}".` })
613
516
  }
614
517
 
615
- async getActivePlanToolResult(params: {
518
+ async submitNodeResult(params: {
616
519
  workstreamId: RecordIdInput
617
- includeEvents?: boolean
520
+ emittedBy: string
521
+ input: SubmitExecutionNodeResultArgs
618
522
  }): Promise<ExecutionPlanToolResultData> {
619
- const plan = await this.getActivePlanForWorkstream(params.workstreamId, { includeEvents: params.includeEvents })
620
- return this.buildToolResult({
621
- action: plan ? 'loaded' : 'none',
622
- plan,
623
- ...(plan ? { message: `Loaded execution plan "${plan.title}".` } : { message: 'No active execution plan.' }),
523
+ return await planExecutorService.submitNodeResult({
524
+ workstreamId: params.workstreamId,
525
+ runId: params.input.runId,
526
+ nodeId: params.input.nodeId,
527
+ emittedBy: params.emittedBy,
528
+ result: params.input.result,
624
529
  })
625
530
  }
626
531
 
627
- async setTaskStatus(params: {
532
+ async resumeRun(params: {
628
533
  workstreamId: RecordIdInput
629
534
  emittedBy: string
630
- input: SetExecutionTaskStatusArgs
535
+ input: ResumeExecutionPlanRunArgs
631
536
  }): Promise<ExecutionPlanToolResultData> {
632
- const activePlan = await this.getActivePlanRecord(params.workstreamId)
633
- if (!activePlan) {
634
- throw new Error('No active execution plan exists for this workstream.')
635
- }
636
- if (recordIdToString(activePlan.id, TABLES.PLAN) !== params.input.planId) {
637
- throw new Error('Task updates must target the active execution plan.')
638
- }
639
-
640
- const task = await this.getTaskById(params.input.taskId)
641
- if (recordIdToString(task.planId, TABLES.PLAN) !== params.input.planId) {
642
- throw new Error('Execution task does not belong to the provided plan.')
643
- }
644
- assertTaskStatusTransition(task, params.input.status, task.title)
645
-
646
- const tasks = await this.listPlanTasks(activePlan.id)
647
- const otherInProgressTask = tasks.find(
648
- (candidate) =>
649
- candidate.status === 'in-progress' &&
650
- recordIdToString(candidate.id, TABLES.PLAN_TASK) !== recordIdToString(task.id, TABLES.PLAN_TASK),
651
- )
652
- if (params.input.status === 'in-progress' && otherInProgressTask) {
653
- throw new Error('Only one execution-plan task may be in progress at a time.')
654
- }
655
-
656
- if (params.input.status === 'pending') {
657
- throw new Error('Use restartExecutionTask to reset a task back to pending.')
658
- }
659
-
660
- if (params.input.status === 'completed') {
661
- if (!params.input.resultStatus) {
662
- throw new Error('Completed tasks require resultStatus.')
663
- }
664
- if (!SUCCESSFUL_RESULT_STATUS_SET.has(params.input.resultStatus)) {
665
- throw new Error('Completed tasks require resultStatus to be success or partial.')
666
- }
667
- if (!params.input.outputSummary?.trim() && !params.input.externalRef?.trim()) {
668
- throw new Error('Completed tasks require outputSummary or externalRef.')
669
- }
670
- }
671
-
672
- if (params.input.status === 'blocked' && !params.input.blockedReason?.trim()) {
673
- throw new Error('Blocked tasks require blockedReason.')
674
- }
675
-
676
- if (params.input.status === 'failed' && !params.input.outputSummary?.trim()) {
677
- throw new Error('Failed tasks require outputSummary.')
678
- }
679
-
680
- const now = new Date()
681
- let nextPlan = activePlan
682
- let nextResultStatus = task.resultStatus
683
- let nextFailureCount = activePlan.failureCount
684
- let taskStartedAt: Date | null | undefined = task.startedAt
685
- ? undefined
686
- : params.input.status === 'in-progress'
687
- ? now
688
- : undefined
689
- let taskCompletedAt: Date | null | undefined = undefined
690
- let message = ''
691
-
692
- if (params.input.status === 'in-progress') {
693
- nextPlan = await this.updatePlanStatus({
694
- plan: activePlan,
695
- status: 'executing',
696
- currentTaskId: task.id,
697
- restartFromTaskId: null,
698
- ...(activePlan.startedAt ? {} : { startedAt: now }),
699
- emittedBy: params.emittedBy,
700
- })
701
- nextResultStatus = task.resultStatus
702
- message = `Started task "${task.title}".`
703
- } else if (params.input.status === 'completed' || params.input.status === 'skipped') {
704
- nextResultStatus =
705
- params.input.status === 'completed'
706
- ? ExecutionPlanTaskResultStatusSchema.parse(params.input.resultStatus)
707
- : (params.input.resultStatus ?? 'not-started')
708
- taskCompletedAt = now
709
-
710
- const refreshedTasks = tasks.map((candidate) =>
711
- recordIdToString(candidate.id, TABLES.PLAN_TASK) === recordIdToString(task.id, TABLES.PLAN_TASK)
712
- ? { ...candidate, status: params.input.status, resultStatus: nextResultStatus }
713
- : candidate,
714
- )
715
- const nextPendingTask =
716
- refreshedTasks.find((candidate) => candidate.position > task.position && candidate.status === 'pending') ??
717
- refreshedTasks.find((candidate) => candidate.status === 'pending') ??
718
- null
719
-
720
- if (nextPendingTask) {
721
- nextPlan = await this.updatePlanStatus({
722
- plan: activePlan,
723
- status: 'executing',
724
- currentTaskId: nextPendingTask.id,
725
- restartFromTaskId: null,
726
- failureCount: 0,
727
- emittedBy: params.emittedBy,
728
- })
729
- } else {
730
- nextPlan = await this.updatePlanStatus({
731
- plan: activePlan,
732
- status: 'completed',
733
- currentTaskId: null,
734
- restartFromTaskId: null,
735
- failureCount: 0,
736
- completedAt: now,
737
- ...(activePlan.startedAt ? {} : { startedAt: now }),
738
- emittedBy: params.emittedBy,
739
- })
740
- }
741
-
742
- nextFailureCount = 0
743
- message =
744
- params.input.status === 'completed' ? `Completed task "${task.title}".` : `Skipped task "${task.title}".`
745
- } else {
746
- nextFailureCount = activePlan.failureCount + 1
747
- nextResultStatus =
748
- params.input.status === 'failed'
749
- ? 'failed'
750
- : (params.input.resultStatus ?? (params.input.outputSummary?.trim() ? 'partial' : task.resultStatus))
751
- nextPlan = await this.updatePlanStatus({
752
- plan: activePlan,
753
- status: 'blocked',
754
- currentTaskId: task.id,
755
- restartFromTaskId: task.id,
756
- failureCount: nextFailureCount,
757
- emittedBy: params.emittedBy,
758
- })
759
- if (params.input.status === 'failed') {
760
- taskCompletedAt = now
761
- }
762
- message = params.input.status === 'failed' ? `Task "${task.title}" failed.` : `Task "${task.title}" is blocked.`
763
- }
764
-
765
- const updatedTask = await this.updateTaskRecord({
766
- task,
767
- status: params.input.status,
768
- resultStatus: nextResultStatus,
769
- ...(params.input.outputSummary !== undefined ? { outputSummary: params.input.outputSummary || null } : {}),
770
- ...(params.input.blockedReason !== undefined ? { blockedReason: params.input.blockedReason || null } : {}),
771
- ...(params.input.externalRef !== undefined ? { externalRef: params.input.externalRef || null } : {}),
772
- ...(params.input.status === 'in-progress' && taskStartedAt ? { startedAt: taskStartedAt } : {}),
773
- ...(taskCompletedAt !== undefined ? { completedAt: taskCompletedAt } : {}),
774
- })
775
-
776
- const eventType =
777
- params.input.status === 'in-progress'
778
- ? 'task-started'
779
- : params.input.status === 'completed'
780
- ? 'task-completed'
781
- : params.input.status === 'blocked'
782
- ? 'task-blocked'
783
- : params.input.status === 'failed'
784
- ? 'task-failed'
785
- : 'task-skipped'
786
-
787
- await this.emitEvent({
788
- planId: nextPlan.id,
789
- taskId: updatedTask.id,
790
- eventType,
791
- fromStatus: task.status,
792
- toStatus: updatedTask.status,
793
- message,
794
- detail: {
795
- resultStatus: updatedTask.resultStatus,
796
- outputSummary: updatedTask.outputSummary,
797
- blockedReason: updatedTask.blockedReason,
798
- externalRef: updatedTask.externalRef,
799
- failureCount: nextFailureCount,
800
- },
537
+ return await planExecutorService.resumeRun({
538
+ workstreamId: params.workstreamId,
539
+ runId: params.input.runId,
801
540
  emittedBy: params.emittedBy,
802
541
  })
542
+ }
803
543
 
804
- const serializable = await this.toSerializablePlan(nextPlan, { includeEvents: true })
805
- return this.buildToolResult({
806
- action: 'task-status-updated',
807
- plan: serializable,
808
- message:
809
- nextFailureCount >= 2 && nextPlan.status === 'blocked'
810
- ? `${message} Failure budget reached; the plan is paused until it is restarted or replaced.`
811
- : message,
812
- changedTaskId: recordIdToString(updatedTask.id, TABLES.PLAN_TASK),
544
+ async applyApprovalResponseFromMessages(params: {
545
+ workstreamId: RecordIdInput
546
+ approvalMessages: ChatMessage[]
547
+ respondedBy: string
548
+ }): Promise<SerializableExecutionPlan | null> {
549
+ const approvalResponse = buildApprovalResponseFromMessages(params.approvalMessages)
550
+ if (!approvalResponse) return null
551
+
552
+ return await planExecutorService.submitHumanNodeResponse({
553
+ workstreamId: params.workstreamId,
554
+ approvalId: approvalResponse.approvalId,
555
+ respondedBy: params.respondedBy,
556
+ response: approvalResponse.response,
813
557
  })
814
558
  }
815
559
 
816
- async restartTask(params: {
560
+ async respondToApproval(params: {
817
561
  workstreamId: RecordIdInput
818
562
  emittedBy: string
819
- input: RestartExecutionTaskArgs
820
- }): Promise<ExecutionPlanToolResultData> {
821
- const activePlan = await this.getActivePlanRecord(params.workstreamId)
822
- if (!activePlan) {
823
- throw new Error('No active execution plan exists for this workstream.')
824
- }
825
- if (recordIdToString(activePlan.id, TABLES.PLAN) !== params.input.planId) {
826
- throw new Error('Only the active execution plan can restart tasks.')
827
- }
563
+ input: { approvalId: string; response: Record<string, unknown>; approvalMessageId?: string }
564
+ }): Promise<SerializableExecutionPlan | null> {
565
+ return await planExecutorService.submitHumanNodeResponse({
566
+ workstreamId: params.workstreamId,
567
+ approvalId: params.input.approvalId,
568
+ respondedBy: params.emittedBy,
569
+ response: params.input.response,
570
+ approvalMessageId: params.input.approvalMessageId,
571
+ })
572
+ }
828
573
 
829
- const tasks = await this.listPlanTasks(activePlan.id)
830
- const targetTask = tasks.find((task) => recordIdToString(task.id, TABLES.PLAN_TASK) === params.input.taskId)
831
- if (!targetTask) {
832
- throw new Error('Execution plan task not found for restart.')
574
+ async applyHumanInputFromUserMessage(params: {
575
+ workstreamId: RecordIdInput
576
+ message: ChatMessage
577
+ respondedBy: string
578
+ }): Promise<SerializableExecutionPlan | null> {
579
+ const run = await planRunService.getActiveRunRecord(params.workstreamId)
580
+ if (!run || run.status !== 'awaiting-human' || !run.waitingNodeId) return null
581
+
582
+ const nodeSpec = await planRunService.getNodeSpecByNodeId(run.planSpecId, run.waitingNodeId)
583
+ if (nodeSpec.type === 'human-approval') {
584
+ return null
833
585
  }
834
- if (targetTask.status !== 'blocked' && targetTask.status !== 'failed') {
835
- throw new Error('Only blocked or failed execution tasks can be restarted.')
586
+ if (
587
+ nodeSpec.type !== 'human-input' &&
588
+ nodeSpec.type !== 'human-review-edit' &&
589
+ nodeSpec.type !== 'human-decision'
590
+ ) {
591
+ return null
836
592
  }
837
593
 
838
- for (const task of tasks) {
839
- const shouldReset =
840
- recordIdToString(task.id, TABLES.PLAN_TASK) === params.input.taskId ||
841
- (params.input.resetDownstream && task.position > targetTask.position)
842
-
843
- if (!shouldReset) continue
844
-
845
- const nextStatus: ExecutionPlanTaskStatus = 'pending'
846
- await this.updateTaskRecord({
847
- task,
848
- status: nextStatus,
849
- resultStatus: 'not-started',
850
- outputSummary: null,
851
- blockedReason: null,
852
- externalRef: null,
853
- startedAt: null,
854
- completedAt: null,
855
- })
594
+ const response = {
595
+ responseText: extractMessageText(params.message).trim(),
596
+ messageId: params.message.id,
597
+ approved: nodeSpec.type === 'human-decision' ? true : undefined,
598
+ } satisfies Record<string, unknown>
599
+
600
+ return await planExecutorService.submitHumanNodeResponse({
601
+ workstreamId: params.workstreamId,
602
+ respondedBy: params.respondedBy,
603
+ response,
604
+ approvalMessageId: params.message.id,
605
+ })
606
+ }
607
+
608
+ private async createNodeSpecs(
609
+ tx: DatabaseTransaction,
610
+ planSpecId: RecordIdInput,
611
+ nodes: CompiledPlanNode[],
612
+ ): Promise<PlanNodeSpecRecord[]> {
613
+ const createdRecords: PlanNodeSpecRecord[] = []
614
+
615
+ for (const compiledNode of nodes) {
616
+ const nodeSpecId = new RecordId(TABLES.PLAN_NODE_SPEC, Bun.randomUUIDv7())
617
+ const created = await tx
618
+ .create(nodeSpecId)
619
+ .content({
620
+ planSpecId: ensureRecordId(planSpecId, TABLES.PLAN_SPEC),
621
+ nodeId: compiledNode.node.id,
622
+ position: compiledNode.position,
623
+ type: compiledNode.node.type,
624
+ label: compiledNode.node.label,
625
+ owner: compiledNode.node.owner,
626
+ objective: compiledNode.node.objective,
627
+ instructions: compiledNode.node.instructions,
628
+ ...(compiledNode.node.inputSchemaRef ? { inputSchemaRef: compiledNode.node.inputSchemaRef } : {}),
629
+ ...(compiledNode.node.outputSchemaRef ? { outputSchemaRef: compiledNode.node.outputSchemaRef } : {}),
630
+ deliverables: [...compiledNode.node.deliverables],
631
+ successCriteria: [...compiledNode.node.successCriteria],
632
+ completionChecks: [...compiledNode.node.completionChecks],
633
+ retryPolicy: { ...compiledNode.node.retryPolicy, retryOn: [...compiledNode.node.retryPolicy.retryOn] },
634
+ failurePolicy: [...compiledNode.node.failurePolicy],
635
+ ...(compiledNode.node.timeoutMs ? { timeoutMs: compiledNode.node.timeoutMs } : {}),
636
+ toolPolicy: { allow: [...compiledNode.node.toolPolicy.allow], deny: [...compiledNode.node.toolPolicy.deny] },
637
+ contextPolicy: {
638
+ retrievalScopes: [...compiledNode.node.contextPolicy.retrievalScopes],
639
+ attachmentPolicy: compiledNode.node.contextPolicy.attachmentPolicy,
640
+ webPolicy: compiledNode.node.contextPolicy.webPolicy,
641
+ },
642
+ upstreamNodeIds: [...compiledNode.upstreamNodeIds],
643
+ downstreamNodeIds: [...compiledNode.downstreamNodeIds],
644
+ })
645
+ .output('after')
646
+
647
+ createdRecords.push(PlanNodeSpecRecordSchema.parse(created))
856
648
  }
857
649
 
858
- const nextPlan = await this.updatePlanStatus({
859
- plan: activePlan,
860
- status: 'executing',
861
- currentTaskId: targetTask.id,
862
- restartFromTaskId: targetTask.id,
863
- failureCount: 0,
864
- completedAt: null,
865
- ...(activePlan.startedAt ? {} : { startedAt: new Date() }),
866
- emittedBy: params.emittedBy,
867
- })
650
+ return createdRecords
651
+ }
868
652
 
869
- await this.emitEvent({
870
- planId: nextPlan.id,
871
- taskId: targetTask.id,
872
- eventType: 'task-restarted',
873
- fromStatus: targetTask.status,
874
- toStatus: 'pending',
875
- message: `Restarted task "${targetTask.title}".`,
876
- detail: { resetDownstream: params.input.resetDownstream },
877
- emittedBy: params.emittedBy,
878
- })
653
+ private async createNodeRuns(
654
+ tx: DatabaseTransaction,
655
+ runId: RecordIdInput,
656
+ planSpecId: RecordIdInput,
657
+ nodeSpecs: PlanNodeSpecRecord[],
658
+ ): Promise<PlanNodeRunRecord[]> {
659
+ const createdNodeRuns: PlanNodeRunRecord[] = []
660
+ for (const nodeSpec of nodeSpecs) {
661
+ const nodeRunId = new RecordId(TABLES.PLAN_NODE_RUN, Bun.randomUUIDv7())
662
+ const created = await tx
663
+ .create(nodeRunId)
664
+ .content({
665
+ runId: ensureRecordId(runId, TABLES.PLAN_RUN),
666
+ planSpecId: ensureRecordId(planSpecId, TABLES.PLAN_SPEC),
667
+ nodeId: nodeSpec.nodeId,
668
+ status: 'pending',
669
+ attemptCount: 0,
670
+ retryCount: 0,
671
+ })
672
+ .output('after')
879
673
 
880
- const serializable = await this.toSerializablePlan(nextPlan, { includeEvents: true })
881
- return this.buildToolResult({
882
- action: 'task-restarted',
883
- plan: serializable,
884
- changedTaskId: recordIdToString(targetTask.id, TABLES.PLAN_TASK),
885
- message: `Restarted task "${targetTask.title}".`,
886
- })
674
+ createdNodeRuns.push(PlanNodeRunSchema.parse(created))
675
+ }
676
+
677
+ return createdNodeRuns
887
678
  }
888
679
  }
889
680