@lota-sdk/core 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/infrastructure/schema/00_workstream.surql +1 -0
  2. package/infrastructure/schema/02_execution_plan.surql +202 -52
  3. package/package.json +4 -2
  4. package/src/bifrost/bifrost.ts +94 -25
  5. package/src/config/model-constants.ts +8 -6
  6. package/src/db/memory-store.ts +3 -71
  7. package/src/db/service.ts +42 -2
  8. package/src/db/tables.ts +9 -2
  9. package/src/embeddings/provider.ts +92 -21
  10. package/src/index.ts +6 -0
  11. package/src/redis/stream-context.ts +44 -0
  12. package/src/runtime/approval-continuation.ts +59 -0
  13. package/src/runtime/chat-request-routing.ts +5 -1
  14. package/src/runtime/execution-plan.ts +21 -14
  15. package/src/runtime/turn-lifecycle.ts +12 -4
  16. package/src/services/document-chunk.service.ts +2 -2
  17. package/src/services/execution-plan.service.ts +579 -786
  18. package/src/services/learned-skill.service.ts +2 -2
  19. package/src/services/plan-approval.service.ts +83 -0
  20. package/src/services/plan-artifact.service.ts +45 -0
  21. package/src/services/plan-builder.service.ts +61 -0
  22. package/src/services/plan-checkpoint.service.ts +53 -0
  23. package/src/services/plan-compiler.service.ts +81 -0
  24. package/src/services/plan-executor.service.ts +1623 -0
  25. package/src/services/plan-run.service.ts +422 -0
  26. package/src/services/plan-validator.service.ts +760 -0
  27. package/src/services/workstream-turn-preparation.ts +57 -15
  28. package/src/services/workstream-turn.ts +12 -0
  29. package/src/services/workstream.service.ts +26 -0
  30. package/src/services/workstream.types.ts +1 -0
  31. package/src/system-agents/title-generator.agent.ts +2 -2
  32. package/src/tools/execution-plan.tool.ts +20 -46
  33. package/src/tools/log-hello-world.tool.ts +17 -0
  34. package/src/workers/skill-extraction.runner.ts +2 -2
@@ -1,889 +1,682 @@
1
+ import type { ChatMessage } from '@lota-sdk/shared/schemas/chat-message'
1
2
  import {
2
- ExecutionPlanEventSchema,
3
- ExecutionPlanSchema,
4
- ExecutionPlanTaskResultStatusSchema,
5
- ExecutionPlanTaskSchema,
3
+ PlanCheckpointSchema,
4
+ PlanEventSchema,
5
+ PlanNodeRunSchema,
6
+ PlanNodeSpecRecordSchema,
7
+ PlanRunSchema,
8
+ PlanSpecSchema,
6
9
  } from '@lota-sdk/shared/schemas/execution-plan'
7
10
  import type {
8
- ExecutionPlanEventRecord,
9
- ExecutionPlanRecord,
10
- ExecutionPlanStatus,
11
- ExecutionPlanTaskRecord,
12
- ExecutionPlanTaskResultStatus,
13
- ExecutionPlanTaskStatus,
11
+ PlanNodeRunRecord,
12
+ PlanNodeSpecRecord,
13
+ PlanRunRecord,
14
+ PlanSpecRecord,
14
15
  SerializableExecutionPlan,
15
- SerializableExecutionPlanCarriedTask,
16
- SerializableExecutionPlanEvent,
17
- SerializableExecutionPlanTask,
18
16
  } from '@lota-sdk/shared/schemas/execution-plan'
19
17
  import type {
20
18
  CreateExecutionPlanArgs,
21
19
  ExecutionPlanToolResultData,
20
+ GetActiveExecutionPlanArgs,
22
21
  ReplaceExecutionPlanArgs,
23
- SetExecutionTaskStatusArgs,
24
- RestartExecutionTaskArgs,
22
+ ResumeExecutionPlanRunArgs,
23
+ SubmitExecutionNodeResultArgs,
25
24
  } from '@lota-sdk/shared/schemas/tools'
26
- import { BoundQuery, RecordId } from 'surrealdb'
25
+ import { RecordId } from 'surrealdb'
27
26
 
28
- import type { RecordIdInput, RecordIdRef } from '../db/record-id'
27
+ import type { RecordIdInput } from '../db/record-id'
29
28
  import { ensureRecordId, recordIdToString } from '../db/record-id'
30
29
  import { databaseService } from '../db/service'
30
+ import type { DatabaseTransaction } from '../db/service'
31
31
  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 {
32
+ import { readApprovalContinuationResponse } from '../runtime/approval-continuation'
33
+ import { extractMessageText } from '../runtime/workstream-chat-helpers'
34
+ import { planBuilderService } from './plan-builder.service'
35
+ import type { CompiledPlanNode } from './plan-compiler.service'
36
+ import { planCompilerService } from './plan-compiler.service'
37
+ import { planExecutorService } from './plan-executor.service'
38
+ import { planRunService } from './plan-run.service'
39
+ import { planValidatorService } from './plan-validator.service'
40
+
41
+ function buildToolResult(params: {
42
+ action: ExecutionPlanToolResultData['action']
43
+ plan: SerializableExecutionPlan | null
44
+ message: string
45
+ changedNodeId?: string
46
+ }): ExecutionPlanToolResultData {
107
47
  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),
48
+ action: params.action,
49
+ message: params.message,
50
+ ...(params.changedNodeId ? { changedNodeId: params.changedNodeId } : {}),
51
+ ...(params.plan ? { plan: params.plan } : {}),
52
+ hasPlan: params.plan !== null,
53
+ status: params.plan?.status,
125
54
  }
126
55
  }
127
56
 
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
- }
57
+ function aggregateBlockingIssues(issues: Array<{ code: string; message: string }>): string {
58
+ return issues.map((issue) => `${issue.code}: ${issue.message}`).join(' | ')
140
59
  }
141
60
 
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
-
61
+ function toSpecData(spec: PlanSpecRecord, patch: Partial<PlanSpecRecord> & { replacedSpecId?: RecordIdInput | null }) {
156
62
  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,
63
+ organizationId: ensureRecordId(spec.organizationId, TABLES.ORGANIZATION),
64
+ workstreamId: ensureRecordId(spec.workstreamId, TABLES.WORKSTREAM),
65
+ title: patch.title ?? spec.title,
66
+ objective: patch.objective ?? spec.objective,
67
+ version: patch.version ?? spec.version,
68
+ status: patch.status ?? spec.status,
69
+ leadAgentId: patch.leadAgentId ?? spec.leadAgentId,
70
+ schemaRegistry: patch.schemaRegistry ? structuredClone(patch.schemaRegistry) : structuredClone(spec.schemaRegistry),
71
+ edges: patch.edges ? [...patch.edges] : [...spec.edges],
72
+ entryNodeIds: patch.entryNodeIds ? [...patch.entryNodeIds] : [...spec.entryNodeIds],
73
+ ...(patch.replacedSpecId
74
+ ? { replacedSpecId: ensureRecordId(patch.replacedSpecId, TABLES.PLAN_SPEC) }
75
+ : spec.replacedSpecId
76
+ ? { replacedSpecId: ensureRecordId(spec.replacedSpecId, TABLES.PLAN_SPEC) }
77
+ : {}),
78
+ ...(patch.compiledAt !== undefined
79
+ ? { compiledAt: patch.compiledAt }
80
+ : spec.compiledAt
81
+ ? { compiledAt: spec.compiledAt }
82
+ : {}),
165
83
  }
166
84
  }
167
85
 
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
86
+ type PlanRunUpdate = Omit<
87
+ Partial<PlanRunRecord>,
88
+ 'currentNodeId' | 'waitingNodeId' | 'replacedRunId' | 'lastCheckpointId' | 'startedAt' | 'completedAt'
89
+ > & {
90
+ currentNodeId?: string | null
91
+ waitingNodeId?: string | null
92
+ replacedRunId?: RecordIdInput | null
93
+ lastCheckpointId?: RecordIdInput | null
94
+ startedAt?: Date | null
95
+ completedAt?: Date | null
179
96
  }
180
97
 
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
98
+ function toRunData(run: PlanRunRecord, patch: PlanRunUpdate) {
99
+ return {
100
+ planSpecId: ensureRecordId(run.planSpecId, TABLES.PLAN_SPEC),
101
+ organizationId: ensureRecordId(run.organizationId, TABLES.ORGANIZATION),
102
+ workstreamId: ensureRecordId(run.workstreamId, TABLES.WORKSTREAM),
103
+ leadAgentId: patch.leadAgentId ?? run.leadAgentId,
104
+ status: patch.status ?? run.status,
105
+ ...(patch.currentNodeId === null
106
+ ? {}
107
+ : patch.currentNodeId !== undefined
108
+ ? { currentNodeId: patch.currentNodeId }
109
+ : run.currentNodeId
110
+ ? { currentNodeId: run.currentNodeId }
111
+ : {}),
112
+ ...(patch.waitingNodeId === null
113
+ ? {}
114
+ : patch.waitingNodeId !== undefined
115
+ ? { waitingNodeId: patch.waitingNodeId }
116
+ : run.waitingNodeId
117
+ ? { waitingNodeId: run.waitingNodeId }
118
+ : {}),
119
+ readyNodeIds: patch.readyNodeIds ? [...patch.readyNodeIds] : [...run.readyNodeIds],
120
+ failureCount: patch.failureCount ?? run.failureCount,
121
+ ...(patch.replacedRunId === null
122
+ ? {}
123
+ : patch.replacedRunId
124
+ ? { replacedRunId: ensureRecordId(patch.replacedRunId, TABLES.PLAN_RUN) }
125
+ : run.replacedRunId
126
+ ? { replacedRunId: ensureRecordId(run.replacedRunId, TABLES.PLAN_RUN) }
127
+ : {}),
128
+ ...(patch.lastCheckpointId === null
129
+ ? {}
130
+ : patch.lastCheckpointId
131
+ ? { lastCheckpointId: ensureRecordId(patch.lastCheckpointId, TABLES.PLAN_CHECKPOINT) }
132
+ : run.lastCheckpointId
133
+ ? { lastCheckpointId: ensureRecordId(run.lastCheckpointId, TABLES.PLAN_CHECKPOINT) }
134
+ : {}),
135
+ ...(patch.startedAt === null
136
+ ? {}
137
+ : patch.startedAt !== undefined
138
+ ? { startedAt: patch.startedAt }
139
+ : run.startedAt
140
+ ? { startedAt: run.startedAt }
141
+ : {}),
142
+ ...(patch.completedAt === null
143
+ ? {}
144
+ : patch.completedAt !== undefined
145
+ ? { completedAt: patch.completedAt }
146
+ : run.completedAt
147
+ ? { completedAt: run.completedAt }
148
+ : {}),
196
149
  }
197
-
198
- throw new Error(`Cannot change execution task "${taskTitle}" from ${task.status} to ${nextStatus}.`)
199
150
  }
200
151
 
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()}`)
152
+ function buildApprovalResponseFromMessages(
153
+ messages: ChatMessage[],
154
+ ): { approvalId: string; response: Record<string, unknown> } | null {
155
+ for (const message of [...messages].reverse()) {
156
+ if (message.role !== 'assistant') continue
157
+ const approvalResponse = readApprovalContinuationResponse(message)
158
+ if (!approvalResponse) continue
159
+
160
+ const response: Record<string, unknown> = {
161
+ approved: approvalResponse.approved,
162
+ requiredEdits: [...approvalResponse.requiredEdits],
207
163
  }
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,
164
+ if (approvalResponse.comments) {
165
+ response.comments = approvalResponse.comments
246
166
  }
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
167
 
252
- await databaseService.create(TABLES.PLAN_EVENT, eventData, ExecutionPlanEventSchema)
168
+ return { approvalId: approvalResponse.approvalId, response }
253
169
  }
254
170
 
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)}`)
289
- }
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
382
- }
383
-
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
- }
417
- }
418
-
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
- }
171
+ return null
172
+ }
434
173
 
174
+ class ExecutionPlanService {
435
175
  async hasActivePlan(workstreamId: RecordIdInput): Promise<boolean> {
436
- return (await this.getActivePlanRecord(workstreamId)) !== null
176
+ return (await planRunService.getActiveRunRecord(workstreamId)) !== null
437
177
  }
438
178
 
439
179
  async getActivePlanForWorkstream(
440
180
  workstreamId: RecordIdInput,
441
- options?: { includeEvents?: boolean },
181
+ options?: Partial<GetActiveExecutionPlanArgs>,
442
182
  ): Promise<SerializableExecutionPlan | null> {
443
- const plan = await this.getActivePlanRecord(workstreamId)
444
- if (!plan) return null
445
- return await this.toSerializablePlan(plan, options)
183
+ const run = await planRunService.getActiveRunRecord(workstreamId)
184
+ if (!run) return null
185
+
186
+ return await planRunService.toSerializablePlan(run, {
187
+ includeEvents: options?.includeEvents,
188
+ includeArtifacts: options?.includeArtifacts,
189
+ includeApprovals: options?.includeApprovals,
190
+ includeCheckpoints: options?.includeCheckpoints,
191
+ includeValidationIssues: options?.includeValidationIssues,
192
+ })
193
+ }
194
+
195
+ async getActivePlanToolResult(params: {
196
+ workstreamId: RecordIdInput
197
+ includeEvents?: boolean
198
+ includeArtifacts?: boolean
199
+ includeApprovals?: boolean
200
+ includeCheckpoints?: boolean
201
+ includeValidationIssues?: boolean
202
+ }): Promise<ExecutionPlanToolResultData> {
203
+ const plan = await this.getActivePlanForWorkstream(params.workstreamId, params)
204
+ return buildToolResult({
205
+ action: plan ? 'loaded' : 'none',
206
+ plan,
207
+ message: plan ? `Loaded execution run "${plan.title}".` : 'No active execution run.',
208
+ })
446
209
  }
447
210
 
448
211
  async createPlan(params: {
449
212
  organizationId: RecordIdInput
450
213
  workstreamId: RecordIdInput
451
- leadAgentId: ExecutionPlanRecord['leadAgentId']
214
+ leadAgentId: string
452
215
  input: CreateExecutionPlanArgs
453
216
  }): 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.')
217
+ const activeRun = await planRunService.getActiveRunRecord(params.workstreamId)
218
+ if (activeRun) {
219
+ throw new Error('An active execution run already exists for this workstream. Replace it instead.')
457
220
  }
458
221
 
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,
222
+ const preparedDraft = planBuilderService.prepareDraft(params.input)
223
+ const validation = planValidatorService.validateDraft(preparedDraft)
224
+ if (validation.blocking.length > 0) {
225
+ throw new Error(`Plan draft failed validation: ${aggregateBlockingIssues(validation.blocking)}`)
472
226
  }
227
+ const compiled = planCompilerService.compile(preparedDraft)
228
+
229
+ const specId = new RecordId(TABLES.PLAN_SPEC, Bun.randomUUIDv7())
230
+ const runId = new RecordId(TABLES.PLAN_RUN, Bun.randomUUIDv7())
231
+
232
+ await databaseService.withTransaction(async (tx) => {
233
+ const spec = PlanSpecSchema.parse(
234
+ await tx
235
+ .create(specId)
236
+ .content({
237
+ organizationId: ensureRecordId(params.organizationId, TABLES.ORGANIZATION),
238
+ workstreamId: ensureRecordId(params.workstreamId, TABLES.WORKSTREAM),
239
+ title: compiled.draft.title,
240
+ objective: compiled.draft.objective,
241
+ version: 1,
242
+ status: 'compiled',
243
+ leadAgentId: params.leadAgentId,
244
+ schemaRegistry: structuredClone(compiled.draft.schemas),
245
+ edges: [...compiled.draft.edges],
246
+ entryNodeIds: [...(compiled.draft.entryNodeIds ?? [])],
247
+ compiledAt: new Date(),
248
+ })
249
+ .output('after'),
250
+ )
473
251
 
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
- }
252
+ const nodeSpecs = await this.createNodeSpecs(tx, spec.id, compiled.nodes)
253
+ const run = PlanRunSchema.parse(
254
+ await tx
255
+ .create(runId)
256
+ .content({
257
+ planSpecId: ensureRecordId(spec.id, TABLES.PLAN_SPEC),
258
+ organizationId: ensureRecordId(params.organizationId, TABLES.ORGANIZATION),
259
+ workstreamId: ensureRecordId(params.workstreamId, TABLES.WORKSTREAM),
260
+ leadAgentId: params.leadAgentId,
261
+ status: 'running',
262
+ readyNodeIds: [],
263
+ failureCount: 0,
264
+ startedAt: new Date(),
265
+ })
266
+ .output('after'),
267
+ )
496
268
 
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,
269
+ const nodeRuns = await this.createNodeRuns(tx, run.id, spec.id, nodeSpecs)
270
+ const synced = await planExecutorService.syncRunGraph({
271
+ tx,
272
+ run,
273
+ spec,
274
+ nodeSpecs,
275
+ nodeRuns,
276
+ artifacts: [],
277
+ emittedBy: params.leadAgentId,
278
+ })
279
+
280
+ const event = await tx
281
+ .create(new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7()))
282
+ .content({
283
+ planSpecId: ensureRecordId(spec.id, TABLES.PLAN_SPEC),
284
+ runId: ensureRecordId(synced.run.id, TABLES.PLAN_RUN),
285
+ eventType: 'plan-created',
286
+ message: `Created execution plan "${spec.title}".`,
287
+ emittedBy: params.leadAgentId,
288
+ detail: { title: spec.title, objective: spec.objective, nodeCount: nodeSpecs.length },
289
+ })
290
+ .output('after')
291
+ PlanEventSchema.parse(event)
292
+
293
+ const checkpoint = PlanCheckpointSchema.parse(
294
+ await tx
295
+ .create(new RecordId(TABLES.PLAN_CHECKPOINT, Bun.randomUUIDv7()))
296
+ .content({
297
+ runId: ensureRecordId(synced.run.id, TABLES.PLAN_RUN),
298
+ sequence: 1,
299
+ runStatus: synced.run.status,
300
+ readyNodeIds: [...synced.run.readyNodeIds],
301
+ activeNodeIds: synced.run.currentNodeId ? [synced.run.currentNodeId] : [],
302
+ artifactIds: [],
303
+ lastCompletedNodeIds: synced.nodeRuns
304
+ .filter((nodeRun) => nodeRun.status === 'completed' || nodeRun.status === 'partial')
305
+ .map((nodeRun) => nodeRun.nodeId),
306
+ snapshot: {
307
+ reason: 'plan-created',
308
+ currentNodeId: synced.run.currentNodeId,
309
+ waitingNodeId: synced.run.waitingNodeId,
310
+ readyNodeIds: synced.run.readyNodeIds,
311
+ },
312
+ })
313
+ .output('after'),
314
+ )
315
+
316
+ const updatedRun = PlanRunSchema.parse(
317
+ await tx
318
+ .update(ensureRecordId(synced.run.id, TABLES.PLAN_RUN))
319
+ .content(toRunData(synced.run, { lastCheckpointId: checkpoint.id }))
320
+ .output('after'),
321
+ )
322
+
323
+ const checkpointEvent = await tx
324
+ .create(new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7()))
325
+ .content({
326
+ planSpecId: ensureRecordId(spec.id, TABLES.PLAN_SPEC),
327
+ runId: ensureRecordId(updatedRun.id, TABLES.PLAN_RUN),
328
+ eventType: 'checkpoint-saved',
329
+ message: 'Saved checkpoint 1.',
330
+ emittedBy: 'system',
331
+ detail: { checkpointId: recordIdToString(checkpoint.id, TABLES.PLAN_CHECKPOINT), reason: 'plan-created' },
332
+ })
333
+ .output('after')
334
+ PlanEventSchema.parse(checkpointEvent)
503
335
  })
504
336
 
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}".`,
337
+ const plan = await planRunService.toSerializablePlan(await planRunService.getRunById(runId), {
338
+ includeEvents: true,
339
+ includeArtifacts: true,
340
+ includeApprovals: true,
341
+ includeCheckpoints: true,
342
+ includeValidationIssues: true,
510
343
  })
344
+
345
+ return buildToolResult({ action: 'created', plan, message: `Created execution plan "${plan.title}".` })
511
346
  }
512
347
 
513
348
  async replacePlan(params: {
514
349
  workstreamId: RecordIdInput
515
350
  organizationId: RecordIdInput
516
- leadAgentId: ExecutionPlanRecord['leadAgentId']
351
+ leadAgentId: string
517
352
  input: ReplaceExecutionPlanArgs
518
353
  }): 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.')
354
+ const activeRun = await planRunService.getActiveRunRecord(params.workstreamId)
355
+ if (!activeRun) {
356
+ throw new Error('No active execution run exists for this workstream.')
522
357
  }
523
- if (recordIdToString(activePlan.id, TABLES.PLAN) !== params.input.planId) {
524
- throw new Error('Only the active execution plan can be replaced.')
358
+ if (recordIdToString(activeRun.id, TABLES.PLAN_RUN) !== params.input.runId) {
359
+ throw new Error('Only the active execution run can be replaced.')
525
360
  }
526
361
 
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,
362
+ const activeSpec = await planRunService.getPlanSpecById(activeRun.planSpecId)
363
+ const preparedDraft = planBuilderService.prepareDraft({
544
364
  title: params.input.title,
545
365
  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,
366
+ nodes: params.input.nodes,
367
+ edges: params.input.edges,
368
+ entryNodeIds: params.input.entryNodeIds,
369
+ schemas: params.input.schemas,
370
+ })
371
+ const validation = planValidatorService.validateDraft(preparedDraft)
372
+ if (validation.blocking.length > 0) {
373
+ throw new Error(`Plan draft failed validation: ${aggregateBlockingIssues(validation.blocking)}`)
551
374
  }
375
+ const compiled = planCompilerService.compile(preparedDraft)
552
376
 
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
- }
377
+ const specId = new RecordId(TABLES.PLAN_SPEC, Bun.randomUUIDv7())
378
+ const runId = new RecordId(TABLES.PLAN_RUN, Bun.randomUUIDv7())
575
379
 
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,
380
+ await databaseService.withTransaction(async (tx) => {
381
+ const supersededSpec = PlanSpecSchema.parse(
382
+ await tx
383
+ .update(ensureRecordId(activeSpec.id, TABLES.PLAN_SPEC))
384
+ .content(toSpecData(activeSpec, { status: 'superseded' }))
385
+ .output('after'),
386
+ )
387
+
388
+ const abortedRun = PlanRunSchema.parse(
389
+ await tx
390
+ .update(ensureRecordId(activeRun.id, TABLES.PLAN_RUN))
391
+ .content(
392
+ toRunData(activeRun, {
393
+ status: 'aborted',
394
+ currentNodeId: null,
395
+ waitingNodeId: null,
396
+ readyNodeIds: [],
397
+ completedAt: new Date(),
398
+ }),
399
+ )
400
+ .output('after'),
401
+ )
402
+
403
+ const spec = PlanSpecSchema.parse(
404
+ await tx
405
+ .create(specId)
406
+ .content({
407
+ organizationId: ensureRecordId(params.organizationId, TABLES.ORGANIZATION),
408
+ workstreamId: ensureRecordId(params.workstreamId, TABLES.WORKSTREAM),
409
+ title: compiled.draft.title,
410
+ objective: compiled.draft.objective,
411
+ version: supersededSpec.version + 1,
412
+ status: 'compiled',
413
+ leadAgentId: params.leadAgentId,
414
+ schemaRegistry: structuredClone(compiled.draft.schemas),
415
+ edges: [...compiled.draft.edges],
416
+ entryNodeIds: [...(compiled.draft.entryNodeIds ?? [])],
417
+ replacedSpecId: ensureRecordId(supersededSpec.id, TABLES.PLAN_SPEC),
418
+ compiledAt: new Date(),
419
+ })
420
+ .output('after'),
421
+ )
422
+
423
+ const nodeSpecs = await this.createNodeSpecs(tx, spec.id, compiled.nodes)
424
+ const run = PlanRunSchema.parse(
425
+ await tx
426
+ .create(runId)
427
+ .content({
428
+ planSpecId: ensureRecordId(spec.id, TABLES.PLAN_SPEC),
429
+ organizationId: ensureRecordId(params.organizationId, TABLES.ORGANIZATION),
430
+ workstreamId: ensureRecordId(params.workstreamId, TABLES.WORKSTREAM),
431
+ leadAgentId: params.leadAgentId,
432
+ status: 'running',
433
+ readyNodeIds: [],
434
+ failureCount: 0,
435
+ replacedRunId: ensureRecordId(abortedRun.id, TABLES.PLAN_RUN),
436
+ startedAt: new Date(),
437
+ })
438
+ .output('after'),
439
+ )
440
+
441
+ const nodeRuns = await this.createNodeRuns(tx, run.id, spec.id, nodeSpecs)
442
+ const synced = await planExecutorService.syncRunGraph({
443
+ tx,
444
+ run,
445
+ spec,
446
+ nodeSpecs,
447
+ nodeRuns,
448
+ artifacts: [],
449
+ emittedBy: params.leadAgentId,
450
+ })
451
+
452
+ const replaceEvent = await tx
453
+ .create(new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7()))
454
+ .content({
455
+ planSpecId: ensureRecordId(spec.id, TABLES.PLAN_SPEC),
456
+ runId: ensureRecordId(synced.run.id, TABLES.PLAN_RUN),
457
+ eventType: 'plan-replaced',
458
+ message: `Replaced execution plan "${activeSpec.title}" with "${spec.title}".`,
459
+ emittedBy: params.leadAgentId,
460
+ detail: { reason: params.input.reason, replacedRunId: recordIdToString(abortedRun.id, TABLES.PLAN_RUN) },
461
+ })
462
+ .output('after')
463
+ PlanEventSchema.parse(replaceEvent)
464
+
465
+ const checkpoint = PlanCheckpointSchema.parse(
466
+ await tx
467
+ .create(new RecordId(TABLES.PLAN_CHECKPOINT, Bun.randomUUIDv7()))
468
+ .content({
469
+ runId: ensureRecordId(synced.run.id, TABLES.PLAN_RUN),
470
+ sequence: 1,
471
+ runStatus: synced.run.status,
472
+ readyNodeIds: [...synced.run.readyNodeIds],
473
+ activeNodeIds: synced.run.currentNodeId ? [synced.run.currentNodeId] : [],
474
+ artifactIds: [],
475
+ lastCompletedNodeIds: synced.nodeRuns
476
+ .filter((nodeRun) => nodeRun.status === 'completed' || nodeRun.status === 'partial')
477
+ .map((nodeRun) => nodeRun.nodeId),
478
+ snapshot: {
479
+ reason: 'plan-replaced',
480
+ currentNodeId: synced.run.currentNodeId,
481
+ waitingNodeId: synced.run.waitingNodeId,
482
+ readyNodeIds: synced.run.readyNodeIds,
483
+ },
484
+ })
485
+ .output('after'),
486
+ )
487
+
488
+ const updatedRun = PlanRunSchema.parse(
489
+ await tx
490
+ .update(ensureRecordId(synced.run.id, TABLES.PLAN_RUN))
491
+ .content(toRunData(synced.run, { lastCheckpointId: checkpoint.id }))
492
+ .output('after'),
493
+ )
494
+
495
+ const checkpointEvent = await tx
496
+ .create(new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7()))
497
+ .content({
498
+ planSpecId: ensureRecordId(spec.id, TABLES.PLAN_SPEC),
499
+ runId: ensureRecordId(updatedRun.id, TABLES.PLAN_RUN),
500
+ eventType: 'checkpoint-saved',
501
+ message: 'Saved checkpoint 1.',
502
+ emittedBy: 'system',
503
+ detail: { checkpointId: recordIdToString(checkpoint.id, TABLES.PLAN_CHECKPOINT), reason: 'plan-replaced' },
504
+ })
505
+ .output('after')
506
+ PlanEventSchema.parse(checkpointEvent)
605
507
  })
606
508
 
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}".`,
509
+ const plan = await planRunService.toSerializablePlan(await planRunService.getRunById(runId), {
510
+ includeEvents: true,
511
+ includeArtifacts: true,
512
+ includeApprovals: true,
513
+ includeCheckpoints: true,
514
+ includeValidationIssues: true,
612
515
  })
516
+
517
+ return buildToolResult({ action: 'replaced', plan, message: `Replaced execution plan with "${plan.title}".` })
613
518
  }
614
519
 
615
- async getActivePlanToolResult(params: {
520
+ async submitNodeResult(params: {
616
521
  workstreamId: RecordIdInput
617
- includeEvents?: boolean
522
+ emittedBy: string
523
+ input: SubmitExecutionNodeResultArgs
618
524
  }): 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.' }),
525
+ return await planExecutorService.submitNodeResult({
526
+ workstreamId: params.workstreamId,
527
+ runId: params.input.runId,
528
+ nodeId: params.input.nodeId,
529
+ emittedBy: params.emittedBy,
530
+ result: params.input.result,
624
531
  })
625
532
  }
626
533
 
627
- async setTaskStatus(params: {
534
+ async resumeRun(params: {
628
535
  workstreamId: RecordIdInput
629
536
  emittedBy: string
630
- input: SetExecutionTaskStatusArgs
537
+ input: ResumeExecutionPlanRunArgs
631
538
  }): 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
- },
539
+ return await planExecutorService.resumeRun({
540
+ workstreamId: params.workstreamId,
541
+ runId: params.input.runId,
801
542
  emittedBy: params.emittedBy,
802
543
  })
544
+ }
803
545
 
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),
546
+ async applyApprovalResponseFromMessages(params: {
547
+ workstreamId: RecordIdInput
548
+ approvalMessages: ChatMessage[]
549
+ respondedBy: string
550
+ }): Promise<SerializableExecutionPlan | null> {
551
+ const approvalResponse = buildApprovalResponseFromMessages(params.approvalMessages)
552
+ if (!approvalResponse) return null
553
+
554
+ return await planExecutorService.submitHumanNodeResponse({
555
+ workstreamId: params.workstreamId,
556
+ approvalId: approvalResponse.approvalId,
557
+ respondedBy: params.respondedBy,
558
+ response: approvalResponse.response,
813
559
  })
814
560
  }
815
561
 
816
- async restartTask(params: {
562
+ async respondToApproval(params: {
817
563
  workstreamId: RecordIdInput
818
564
  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
- }
565
+ input: { approvalId: string; response: Record<string, unknown>; approvalMessageId?: string }
566
+ }): Promise<SerializableExecutionPlan | null> {
567
+ return await planExecutorService.submitHumanNodeResponse({
568
+ workstreamId: params.workstreamId,
569
+ approvalId: params.input.approvalId,
570
+ respondedBy: params.emittedBy,
571
+ response: params.input.response,
572
+ approvalMessageId: params.input.approvalMessageId,
573
+ })
574
+ }
828
575
 
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.')
576
+ async applyHumanInputFromUserMessage(params: {
577
+ workstreamId: RecordIdInput
578
+ message: ChatMessage
579
+ respondedBy: string
580
+ }): Promise<SerializableExecutionPlan | null> {
581
+ const run = await planRunService.getActiveRunRecord(params.workstreamId)
582
+ if (!run || run.status !== 'awaiting-human' || !run.waitingNodeId) return null
583
+
584
+ const nodeSpec = await planRunService.getNodeSpecByNodeId(run.planSpecId, run.waitingNodeId)
585
+ if (nodeSpec.type === 'human-approval') {
586
+ return null
833
587
  }
834
- if (targetTask.status !== 'blocked' && targetTask.status !== 'failed') {
835
- throw new Error('Only blocked or failed execution tasks can be restarted.')
588
+ if (
589
+ nodeSpec.type !== 'human-input' &&
590
+ nodeSpec.type !== 'human-review-edit' &&
591
+ nodeSpec.type !== 'human-decision'
592
+ ) {
593
+ return null
836
594
  }
837
595
 
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
- })
596
+ const response = {
597
+ responseText: extractMessageText(params.message).trim(),
598
+ messageId: params.message.id,
599
+ approved: nodeSpec.type === 'human-decision' ? true : undefined,
600
+ } satisfies Record<string, unknown>
601
+
602
+ return await planExecutorService.submitHumanNodeResponse({
603
+ workstreamId: params.workstreamId,
604
+ respondedBy: params.respondedBy,
605
+ response,
606
+ approvalMessageId: params.message.id,
607
+ })
608
+ }
609
+
610
+ private async createNodeSpecs(
611
+ tx: DatabaseTransaction,
612
+ planSpecId: RecordIdInput,
613
+ nodes: CompiledPlanNode[],
614
+ ): Promise<PlanNodeSpecRecord[]> {
615
+ const createdRecords: PlanNodeSpecRecord[] = []
616
+
617
+ for (const compiledNode of nodes) {
618
+ const nodeSpecId = new RecordId(TABLES.PLAN_NODE_SPEC, Bun.randomUUIDv7())
619
+ const created = await tx
620
+ .create(nodeSpecId)
621
+ .content({
622
+ planSpecId: ensureRecordId(planSpecId, TABLES.PLAN_SPEC),
623
+ nodeId: compiledNode.node.id,
624
+ position: compiledNode.position,
625
+ type: compiledNode.node.type,
626
+ label: compiledNode.node.label,
627
+ owner: compiledNode.node.owner,
628
+ objective: compiledNode.node.objective,
629
+ instructions: compiledNode.node.instructions,
630
+ ...(compiledNode.node.inputSchemaRef ? { inputSchemaRef: compiledNode.node.inputSchemaRef } : {}),
631
+ ...(compiledNode.node.outputSchemaRef ? { outputSchemaRef: compiledNode.node.outputSchemaRef } : {}),
632
+ deliverables: [...compiledNode.node.deliverables],
633
+ successCriteria: [...compiledNode.node.successCriteria],
634
+ completionChecks: [...compiledNode.node.completionChecks],
635
+ retryPolicy: { ...compiledNode.node.retryPolicy, retryOn: [...compiledNode.node.retryPolicy.retryOn] },
636
+ failurePolicy: [...compiledNode.node.failurePolicy],
637
+ ...(compiledNode.node.timeoutMs ? { timeoutMs: compiledNode.node.timeoutMs } : {}),
638
+ toolPolicy: { allow: [...compiledNode.node.toolPolicy.allow], deny: [...compiledNode.node.toolPolicy.deny] },
639
+ contextPolicy: {
640
+ retrievalScopes: [...compiledNode.node.contextPolicy.retrievalScopes],
641
+ attachmentPolicy: compiledNode.node.contextPolicy.attachmentPolicy,
642
+ webPolicy: compiledNode.node.contextPolicy.webPolicy,
643
+ },
644
+ upstreamNodeIds: [...compiledNode.upstreamNodeIds],
645
+ downstreamNodeIds: [...compiledNode.downstreamNodeIds],
646
+ })
647
+ .output('after')
648
+
649
+ createdRecords.push(PlanNodeSpecRecordSchema.parse(created))
856
650
  }
857
651
 
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
- })
652
+ return createdRecords
653
+ }
868
654
 
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
- })
655
+ private async createNodeRuns(
656
+ tx: DatabaseTransaction,
657
+ runId: RecordIdInput,
658
+ planSpecId: RecordIdInput,
659
+ nodeSpecs: PlanNodeSpecRecord[],
660
+ ): Promise<PlanNodeRunRecord[]> {
661
+ const createdNodeRuns: PlanNodeRunRecord[] = []
662
+ for (const nodeSpec of nodeSpecs) {
663
+ const nodeRunId = new RecordId(TABLES.PLAN_NODE_RUN, Bun.randomUUIDv7())
664
+ const created = await tx
665
+ .create(nodeRunId)
666
+ .content({
667
+ runId: ensureRecordId(runId, TABLES.PLAN_RUN),
668
+ planSpecId: ensureRecordId(planSpecId, TABLES.PLAN_SPEC),
669
+ nodeId: nodeSpec.nodeId,
670
+ status: 'pending',
671
+ attemptCount: 0,
672
+ retryCount: 0,
673
+ })
674
+ .output('after')
879
675
 
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
- })
676
+ createdNodeRuns.push(PlanNodeRunSchema.parse(created))
677
+ }
678
+
679
+ return createdNodeRuns
887
680
  }
888
681
  }
889
682