@lota-sdk/core 0.4.3 → 0.4.4

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.
@@ -86,7 +86,9 @@ DEFINE TABLE IF NOT EXISTS planRun SCHEMAFULL;
86
86
  DEFINE FIELD IF NOT EXISTS planSpecId ON TABLE planRun TYPE record<planSpec> REFERENCE ON DELETE CASCADE;
87
87
  DEFINE FIELD IF NOT EXISTS organizationId ON TABLE planRun TYPE record<organization>;
88
88
  DEFINE FIELD IF NOT EXISTS threadId ON TABLE planRun TYPE record<thread> REFERENCE ON DELETE CASCADE;
89
+ DEFINE FIELD IF NOT EXISTS sourceThreadId ON TABLE planRun TYPE option<record<thread>>;
89
90
  DEFINE FIELD IF NOT EXISTS leadAgentId ON TABLE planRun TYPE string;
91
+ DEFINE FIELD IF NOT EXISTS createdByAgentId ON TABLE planRun TYPE option<string>;
90
92
  DEFINE FIELD IF NOT EXISTS status ON TABLE planRun TYPE string;
91
93
  DEFINE FIELD IF NOT EXISTS currentNodeId ON TABLE planRun TYPE option<string>;
92
94
  DEFINE FIELD IF NOT EXISTS waitingNodeId ON TABLE planRun TYPE option<string>;
@@ -103,7 +105,9 @@ DEFINE FIELD IF NOT EXISTS completedAt ON TABLE planRun TYPE option<datetime>;
103
105
 
104
106
  DEFINE INDEX IF NOT EXISTS planRunOrgIdx ON TABLE planRun COLUMNS organizationId;
105
107
  DEFINE INDEX IF NOT EXISTS planRunThreadIdx ON TABLE planRun COLUMNS threadId;
108
+ DEFINE INDEX IF NOT EXISTS planRunSourceThreadIdx ON TABLE planRun COLUMNS sourceThreadId;
106
109
  DEFINE INDEX IF NOT EXISTS planRunThreadStatusIdx ON TABLE planRun COLUMNS threadId, status;
110
+ DEFINE INDEX IF NOT EXISTS planRunSourceThreadStatusIdx ON TABLE planRun COLUMNS sourceThreadId, status;
107
111
  DEFINE INDEX IF NOT EXISTS planRunSpecIdx ON TABLE planRun COLUMNS planSpecId;
108
112
 
109
113
  DEFINE TABLE IF NOT EXISTS planNodeRun SCHEMAFULL;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lota-sdk/core",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -32,7 +32,7 @@
32
32
  "@chat-adapter/slack": "^4.23.0",
33
33
  "@chat-adapter/state-ioredis": "^4.23.0",
34
34
  "@logtape/logtape": "^2.0.5",
35
- "@lota-sdk/shared": "0.4.3",
35
+ "@lota-sdk/shared": "0.4.4",
36
36
  "@mendable/firecrawl-js": "^4.18.1",
37
37
  "@surrealdb/node": "^3.0.3",
38
38
  "ai": "^6.0.145",
@@ -16,9 +16,7 @@ function toExecutionPlanPromptSummaries(plans: SerializableExecutionPlan[]): Exe
16
16
  return plans.map(({ runId, title }) => ({ runId, title }))
17
17
  }
18
18
 
19
- function formatExecutionPlansForPrompt(plans: SerializableExecutionPlan[]): string | undefined {
20
- if (plans.length === 0) return undefined
21
-
19
+ function formatExecutionPlansForPrompt(plans: SerializableExecutionPlan[]): string {
22
20
  const payload = { activePlans: toExecutionPlanPromptSummaries(plans), planCount: plans.length }
23
21
 
24
22
  return ['<execution-plan-state>', JSON.stringify(payload, null, 2), '</execution-plan-state>'].join('\n')
@@ -26,10 +24,7 @@ function formatExecutionPlansForPrompt(plans: SerializableExecutionPlan[]): stri
26
24
 
27
25
  export function buildExecutionPlanInstructionSections(plans: SerializableExecutionPlan[] | null | undefined): string[] {
28
26
  const normalized = plans ?? []
29
- const sections = [EXECUTION_PLAN_AGENT_PROTOCOL_PROMPT]
30
- const stateSection = formatExecutionPlansForPrompt(normalized)
31
- if (stateSection) sections.push(stateSection)
32
- return sections
27
+ return [EXECUTION_PLAN_AGENT_PROTOCOL_PROMPT, formatExecutionPlansForPrompt(normalized)]
33
28
  }
34
29
 
35
30
  export function createExecutionPlanInstructionSectionCache(params: {
@@ -41,6 +41,14 @@ type AgentActivityDeps = {
41
41
  threadService: Pick<typeof threadService, 'listThreads'>
42
42
  }
43
43
 
44
+ function isPendingPlanApproval(plan: SerializableExecutionPlan): boolean {
45
+ return plan.status === 'pending-approval'
46
+ }
47
+
48
+ function countPendingPlanApprovals(activePlans: ActivePlanEntry[]): number {
49
+ return activePlans.filter(({ plan }) => isPendingPlanApproval(plan)).length
50
+ }
51
+
44
52
  function normalizeCardStatus(status: string): BoardColumnStatus {
45
53
  if (status === 'pending' || status === 'scheduled') return 'ready'
46
54
  if (status === 'partial') return 'completed'
@@ -145,9 +153,9 @@ export class AgentActivityService {
145
153
 
146
154
  async getBoard(userRef: string, orgRef: string): Promise<PlanBoardResponse> {
147
155
  const activePlans = await this.getAllActivePlans(userRef, orgRef)
148
- const cards = activePlans.flatMap(({ plan, thread }) =>
149
- plan.nodes.map((node) => planNodeToCard(node, plan, thread.id, thread.title)),
150
- )
156
+ const cards = activePlans
157
+ .filter(({ plan }) => !isPendingPlanApproval(plan))
158
+ .flatMap(({ plan, thread }) => plan.nodes.map((node) => planNodeToCard(node, plan, thread.id, thread.title)))
151
159
 
152
160
  const columns: PlanBoardColumn[] = BOARD_COLUMN_ORDER.map((status) => ({
153
161
  status,
@@ -161,7 +169,7 @@ export class AgentActivityService {
161
169
  totalNodes: cards.length,
162
170
  completedNodes: cards.filter((card) => card.status === 'completed').length,
163
171
  activePlanCount: activePlans.length,
164
- pendingApprovalCount: cards.filter((card) => card.hasApproval).length,
172
+ pendingApprovalCount: countPendingPlanApprovals(activePlans) + cards.filter((card) => card.hasApproval).length,
165
173
  },
166
174
  }
167
175
  }
@@ -187,8 +195,13 @@ export class AgentActivityService {
187
195
  async getMyTasks(userRef: string, orgRef: string): Promise<MyTasksResponse> {
188
196
  const activePlans = await this.getAllActivePlans(userRef, orgRef)
189
197
  const tasks: PlanNodeCard[] = []
198
+ const pendingPlanApprovalCount = countPendingPlanApprovals(activePlans)
190
199
 
191
200
  for (const { plan, thread } of activePlans) {
201
+ if (isPendingPlanApproval(plan)) {
202
+ continue
203
+ }
204
+
192
205
  for (const node of plan.nodes) {
193
206
  const humanOwned = node.owner.executorType === 'user'
194
207
  const awaitingHuman = node.status === 'awaiting-human'
@@ -198,7 +211,7 @@ export class AgentActivityService {
198
211
  }
199
212
  }
200
213
 
201
- return { tasks, pendingApprovalCount: tasks.filter((task) => task.hasApproval).length }
214
+ return { tasks, pendingApprovalCount: pendingPlanApprovalCount + tasks.filter((task) => task.hasApproval).length }
202
215
  }
203
216
 
204
217
  async getAgentActivity(userRef: string, orgRef: string): Promise<AgentActivityResponse> {
@@ -210,6 +223,23 @@ export class AgentActivityService {
210
223
  }
211
224
 
212
225
  for (const { plan, thread } of activePlans) {
226
+ if (isPendingPlanApproval(plan)) {
227
+ if (plan.leadAgentId.trim()) {
228
+ const leadEntry = this.ensureEntry(activityByAgent, plan.leadAgentId)
229
+ leadEntry.isLeadingActivePlan = true
230
+ this.ensureProjectEntry(leadEntry.projects, {
231
+ threadId: thread.id,
232
+ threadTitle: thread.title,
233
+ planRunId: plan.runId,
234
+ planTitle: plan.title,
235
+ status: plan.status,
236
+ })
237
+ leadEntry.isRunning = leadEntry.isRunning || thread.isRunning
238
+ leadEntry.lastActiveAt = maxIsoDate(leadEntry.lastActiveAt, thread.updatedAt)
239
+ }
240
+ continue
241
+ }
242
+
213
243
  const involvedAgents = new Set<string>()
214
244
 
215
245
  for (const node of plan.nodes) {
@@ -270,25 +270,25 @@ class ArtifactService {
270
270
 
271
271
  let lastError: unknown = null
272
272
  for (let attempt = 1; attempt <= ARTIFACT_PUBLISH_MAX_ATTEMPTS; attempt += 1) {
273
- let pendingStorageKey: string | null = null
273
+ const publishAttemptState: { pendingStorageKey?: string } = {}
274
274
  try {
275
275
  return await databaseService.withTransaction(
276
276
  async (tx) =>
277
277
  await this.publishArtifactInTransaction(params, tx, {
278
278
  onStorageWrite: (storageKey) => {
279
- pendingStorageKey = storageKey
279
+ publishAttemptState.pendingStorageKey = storageKey
280
280
  },
281
281
  onStorageCleanup: (storageKey) => {
282
- if (pendingStorageKey === storageKey) {
283
- pendingStorageKey = null
282
+ if (publishAttemptState.pendingStorageKey === storageKey) {
283
+ publishAttemptState.pendingStorageKey = undefined
284
284
  }
285
285
  },
286
286
  }),
287
287
  )
288
288
  } catch (error) {
289
- const storageKeyToCleanup = pendingStorageKey
290
- pendingStorageKey = null
291
- if (storageKeyToCleanup !== null) {
289
+ const storageKeyToCleanup = publishAttemptState.pendingStorageKey
290
+ publishAttemptState.pendingStorageKey = undefined
291
+ if (typeof storageKeyToCleanup === 'string') {
292
292
  await generatedDocumentStorageService.deleteTextArtifact(storageKeyToCleanup).catch(() => undefined)
293
293
  }
294
294
  lastError = error
@@ -8,6 +8,7 @@ import type {
8
8
  PlanDraft,
9
9
  PlanNodeRunRecord,
10
10
  PlanNodeSpecRecord,
11
+ PlanRunRecord,
11
12
  PlanSpecRecord,
12
13
  SerializableExecutionPlan,
13
14
  SubmitPlanTurnResultArgs,
@@ -47,6 +48,25 @@ function aggregateBlockingIssues(issues: Array<{ code: string; message: string }
47
48
  return issues.map((issue) => `${issue.code}: ${issue.message}`).join(' | ')
48
49
  }
49
50
 
51
+ function hasCrossThreadSourceContext(sourceThreadId: RecordIdInput | undefined, threadId: RecordIdInput): boolean {
52
+ if (!sourceThreadId) {
53
+ return false
54
+ }
55
+
56
+ return recordIdToString(sourceThreadId, TABLES.THREAD) !== recordIdToString(threadId, TABLES.THREAD)
57
+ }
58
+
59
+ function isPlanVisibleInThreadContext(
60
+ threadId: RecordIdInput,
61
+ params: { threadId: RecordIdInput; sourceThreadId?: RecordIdInput },
62
+ ): boolean {
63
+ const currentThreadId = recordIdToString(threadId, TABLES.THREAD)
64
+ return (
65
+ currentThreadId === recordIdToString(params.threadId, TABLES.THREAD) ||
66
+ (params.sourceThreadId !== undefined && currentThreadId === recordIdToString(params.sourceThreadId, TABLES.THREAD))
67
+ )
68
+ }
69
+
50
70
  function toSpecData(spec: PlanSpecRecord, patch: Partial<PlanSpecRecord> & { replacedSpecId?: RecordIdInput | null }) {
51
71
  return {
52
72
  organizationId: ensureRecordId(spec.organizationId, TABLES.ORGANIZATION),
@@ -168,17 +188,29 @@ class ExecutionPlanService {
168
188
  const runs = await planRunService.getActiveRunRecords(threadId)
169
189
  if (runs.length === 0) return []
170
190
 
171
- return await Promise.all(
172
- runs.map((run) =>
173
- planRunService.toSerializablePlan(run, {
174
- includeEvents: options?.includeEvents,
175
- includeArtifacts: options?.includeArtifacts,
176
- includeApprovals: options?.includeApprovals,
177
- includeCheckpoints: options?.includeCheckpoints,
178
- includeValidationIssues: options?.includeValidationIssues,
179
- }),
180
- ),
181
- )
191
+ return await this.serializeRuns(runs, options)
192
+ }
193
+
194
+ async getPlansCreatedInContext(params: {
195
+ organizationId: RecordIdInput
196
+ sourceThreadId?: RecordIdInput
197
+ createdByAgentId?: string
198
+ statuses?: ReadonlyArray<PlanRunRecord['status']>
199
+ includeEvents?: boolean
200
+ includeArtifacts?: boolean
201
+ includeApprovals?: boolean
202
+ includeCheckpoints?: boolean
203
+ includeValidationIssues?: boolean
204
+ }): Promise<SerializableExecutionPlan[]> {
205
+ const runs = await planRunService.getRunsCreatedInContext({
206
+ organizationId: params.organizationId,
207
+ sourceThreadId: params.sourceThreadId,
208
+ createdByAgentId: params.createdByAgentId,
209
+ statuses: params.statuses,
210
+ })
211
+ if (runs.length === 0) return []
212
+
213
+ return await this.serializeRuns(runs, params)
182
214
  }
183
215
 
184
216
  async listActivePlanSummaries(threadId: RecordIdInput): Promise<ListExecutionPlansToolResultData> {
@@ -245,9 +277,14 @@ class ExecutionPlanService {
245
277
  async createPlan(params: {
246
278
  organizationId: RecordIdInput
247
279
  threadId: RecordIdInput
280
+ sourceThreadId?: RecordIdInput
248
281
  leadAgentId: string
282
+ createdByAgentId?: string
283
+ requireApproval?: boolean
249
284
  input: PlanDraft
250
285
  }): Promise<ExecutionPlanToolResultData> {
286
+ const requireApproval =
287
+ params.requireApproval ?? hasCrossThreadSourceContext(params.sourceThreadId, params.threadId)
251
288
  const preparedDraft = planBuilderService.prepareDraft(PlanDraftSchema.parse(params.input))
252
289
  const validation = planValidatorService.validateDraft(preparedDraft)
253
290
  if (validation.blocking.length > 0) {
@@ -281,7 +318,10 @@ class ExecutionPlanService {
281
318
  spec,
282
319
  organizationId: params.organizationId,
283
320
  threadId: params.threadId,
321
+ sourceThreadId: params.sourceThreadId,
284
322
  leadAgentId: params.leadAgentId,
323
+ createdByAgentId: params.createdByAgentId,
324
+ requireApproval,
285
325
  nodes: compiled.nodes,
286
326
  emittedEvents,
287
327
  createdEventType: 'plan-created',
@@ -293,25 +333,13 @@ class ExecutionPlanService {
293
333
 
294
334
  await planEventDeliveryService.dispatchEvents(emittedEvents)
295
335
 
296
- // Create a plan-level schedule record if the draft specifies a schedule
297
- if (compiled.draft.schedule) {
298
- const schedule = await planSchedulerService.createSchedule({
336
+ if (!requireApproval) {
337
+ await this.attachPlanScheduleIfNeeded({
299
338
  organizationId: params.organizationId,
300
339
  threadId: params.threadId,
301
- planSpecId: specId,
302
340
  runId,
303
- scheduleSpec: compiled.draft.schedule,
341
+ planSpecId: specId,
304
342
  })
305
-
306
- await databaseService.update(
307
- TABLES.PLAN_RUN,
308
- ensureRecordId(runId, TABLES.PLAN_RUN),
309
- {
310
- scheduleId: ensureRecordId(schedule.id, TABLES.PLAN_SCHEDULE),
311
- scheduledAt: schedule.nextFireAt ? toDatabaseDateTime(schedule.nextFireAt) : undefined,
312
- },
313
- PlanRunSchema,
314
- )
315
343
  }
316
344
 
317
345
  const plan = await this.finalizePlanSnapshot({ runId, emittedBy: params.leadAgentId })
@@ -323,7 +351,8 @@ class ExecutionPlanService {
323
351
  threadId: RecordIdInput
324
352
  organizationId: RecordIdInput
325
353
  leadAgentId: string
326
- input: PlanDraft & { runId: string; reason: string }
354
+ createdByAgentId?: string
355
+ input: PlanDraft & { runId: string; reason: string; requireApproval?: boolean }
327
356
  }): Promise<ExecutionPlanToolResultData> {
328
357
  const activeRun = await planRunService.getRunById(params.input.runId)
329
358
  const resolvedThreadId = activeRun.threadId
@@ -337,7 +366,7 @@ class ExecutionPlanService {
337
366
  }
338
367
 
339
368
  const activeSpec = await planRunService.getPlanSpecById(activeRun.planSpecId)
340
- const { runId: _runId, reason: _reason, ...draftInput } = params.input
369
+ const { runId: _runId, reason: _reason, requireApproval: requestedRequireApproval, ...draftInput } = params.input
341
370
  const preparedDraft = planBuilderService.prepareDraft(PlanDraftSchema.parse(draftInput))
342
371
  const validation = planValidatorService.validateDraft(preparedDraft)
343
372
  if (validation.blocking.length > 0) {
@@ -394,7 +423,11 @@ class ExecutionPlanService {
394
423
  spec,
395
424
  organizationId: params.organizationId,
396
425
  threadId: resolvedThreadId,
426
+ sourceThreadId: activeRun.sourceThreadId,
397
427
  leadAgentId: params.leadAgentId,
428
+ createdByAgentId: params.createdByAgentId ?? params.leadAgentId,
429
+ requireApproval:
430
+ requestedRequireApproval ?? hasCrossThreadSourceContext(activeRun.sourceThreadId, resolvedThreadId),
398
431
  nodes: compiled.nodes,
399
432
  emittedEvents,
400
433
  runPatch: { replacedRunId: abortedRun.id },
@@ -410,6 +443,15 @@ class ExecutionPlanService {
410
443
 
411
444
  await planEventDeliveryService.dispatchEvents(emittedEvents)
412
445
 
446
+ if (!(requestedRequireApproval ?? hasCrossThreadSourceContext(activeRun.sourceThreadId, resolvedThreadId))) {
447
+ await this.attachPlanScheduleIfNeeded({
448
+ organizationId: params.organizationId,
449
+ threadId: resolvedThreadId,
450
+ runId,
451
+ planSpecId: specId,
452
+ })
453
+ }
454
+
413
455
  const plan = await this.finalizePlanSnapshot({ runId, emittedBy: params.leadAgentId })
414
456
 
415
457
  return buildExecutionPlanToolResult({
@@ -482,6 +524,243 @@ class ExecutionPlanService {
482
524
  })
483
525
  }
484
526
 
527
+ async approvePlan(params: {
528
+ organizationId: RecordIdInput
529
+ threadId: RecordIdInput
530
+ runId: RecordIdInput
531
+ emittedBy: string
532
+ }): Promise<SerializableExecutionPlan> {
533
+ const run = await planRunService.getRunById(params.runId)
534
+ if (
535
+ recordIdToString(run.organizationId, TABLES.ORGANIZATION) !==
536
+ recordIdToString(params.organizationId, TABLES.ORGANIZATION)
537
+ ) {
538
+ throw new Error('Plan run belongs to a different organization.')
539
+ }
540
+ if (!isPlanVisibleInThreadContext(params.threadId, run)) {
541
+ throw new Error('Plan run is not available in this thread context.')
542
+ }
543
+ if (run.status !== 'pending-approval') {
544
+ throw new Error('Only pending-approval plans can be approved.')
545
+ }
546
+
547
+ const [spec, nodeSpecs, nodeRuns, latestCheckpoint] = await Promise.all([
548
+ planRunService.getPlanSpecById(run.planSpecId),
549
+ planRunService.listNodeSpecs(run.planSpecId),
550
+ planRunService.listNodeRuns(run.id),
551
+ planRunService.getLatestCheckpoint(run.id),
552
+ ])
553
+ const emittedEvents: PlanEventRecord[] = []
554
+
555
+ await databaseService.withTransaction(async (tx) => {
556
+ const activatedRun = PlanRunSchema.parse(
557
+ await tx
558
+ .update(ensureRecordId(run.id, TABLES.PLAN_RUN))
559
+ .content(toRunData(run, { status: 'running', startedAt: run.startedAt ?? new Date() }))
560
+ .output('after'),
561
+ )
562
+
563
+ const synced = await planExecutorService.syncRunGraph({
564
+ tx,
565
+ run: activatedRun,
566
+ spec,
567
+ nodeSpecs,
568
+ nodeRuns,
569
+ artifacts: [],
570
+ emittedBy: params.emittedBy,
571
+ capturedEvents: emittedEvents,
572
+ })
573
+
574
+ const checkpoint = PlanCheckpointSchema.parse(
575
+ await tx
576
+ .create(new RecordId(TABLES.PLAN_CHECKPOINT, Bun.randomUUIDv7()))
577
+ .content({
578
+ runId: ensureRecordId(synced.run.id, TABLES.PLAN_RUN),
579
+ sequence: (latestCheckpoint?.sequence ?? 0) + 1,
580
+ runStatus: synced.run.status,
581
+ readyNodeIds: [...synced.run.readyNodeIds],
582
+ activeNodeIds: synced.run.currentNodeId ? [synced.run.currentNodeId] : [],
583
+ artifactIds: [],
584
+ lastCompletedNodeIds: synced.nodeRuns
585
+ .filter((nodeRun) => nodeRun.status === 'completed' || nodeRun.status === 'partial')
586
+ .map((nodeRun) => nodeRun.nodeId),
587
+ snapshot: {
588
+ reason: 'plan-approved',
589
+ currentNodeId: synced.run.currentNodeId,
590
+ waitingNodeId: synced.run.waitingNodeId,
591
+ readyNodeIds: synced.run.readyNodeIds,
592
+ },
593
+ })
594
+ .output('after'),
595
+ )
596
+
597
+ const updatedRun = PlanRunSchema.parse(
598
+ await tx
599
+ .update(ensureRecordId(synced.run.id, TABLES.PLAN_RUN))
600
+ .content(toRunData(synced.run, { lastCheckpointId: checkpoint.id }))
601
+ .output('after'),
602
+ )
603
+
604
+ const approvedEvent = await tx
605
+ .create(new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7()))
606
+ .content({
607
+ planSpecId: ensureRecordId(spec.id, TABLES.PLAN_SPEC),
608
+ runId: ensureRecordId(updatedRun.id, TABLES.PLAN_RUN),
609
+ eventType: 'plan-approved',
610
+ fromStatus: run.status,
611
+ toStatus: updatedRun.status,
612
+ message: `Approved execution plan "${spec.title}".`,
613
+ emittedBy: params.emittedBy,
614
+ detail: { title: spec.title, objective: spec.objective },
615
+ })
616
+ .output('after')
617
+ emittedEvents.push(PlanEventSchema.parse(approvedEvent))
618
+
619
+ const checkpointEvent = await tx
620
+ .create(new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7()))
621
+ .content({
622
+ planSpecId: ensureRecordId(spec.id, TABLES.PLAN_SPEC),
623
+ runId: ensureRecordId(updatedRun.id, TABLES.PLAN_RUN),
624
+ eventType: 'checkpoint-saved',
625
+ message: `Saved checkpoint ${(latestCheckpoint?.sequence ?? 0) + 1}.`,
626
+ emittedBy: 'system',
627
+ detail: { checkpointId: recordIdToString(checkpoint.id, TABLES.PLAN_CHECKPOINT), reason: 'plan-approved' },
628
+ })
629
+ .output('after')
630
+ emittedEvents.push(PlanEventSchema.parse(checkpointEvent))
631
+ })
632
+
633
+ await planEventDeliveryService.dispatchEvents(emittedEvents)
634
+ await this.attachPlanScheduleIfNeeded({
635
+ organizationId: run.organizationId,
636
+ threadId: run.threadId,
637
+ runId: run.id,
638
+ planSpecId: run.planSpecId,
639
+ })
640
+
641
+ return await this.finalizePlanSnapshot({ runId: run.id, emittedBy: params.emittedBy })
642
+ }
643
+
644
+ async rejectPlan(params: {
645
+ organizationId: RecordIdInput
646
+ threadId: RecordIdInput
647
+ runId: RecordIdInput
648
+ emittedBy: string
649
+ reason?: string
650
+ resolution?: 'rejected' | 'changes-requested'
651
+ }): Promise<SerializableExecutionPlan> {
652
+ const resolution = params.resolution ?? 'rejected'
653
+ const eventType = resolution === 'changes-requested' ? 'plan-changes-requested' : 'plan-rejected'
654
+ const run = await planRunService.getRunById(params.runId)
655
+ if (
656
+ recordIdToString(run.organizationId, TABLES.ORGANIZATION) !==
657
+ recordIdToString(params.organizationId, TABLES.ORGANIZATION)
658
+ ) {
659
+ throw new Error('Plan run belongs to a different organization.')
660
+ }
661
+ if (!isPlanVisibleInThreadContext(params.threadId, run)) {
662
+ throw new Error('Plan run is not available in this thread context.')
663
+ }
664
+ if (run.status !== 'pending-approval') {
665
+ throw new Error('Only pending-approval plans can be rejected.')
666
+ }
667
+
668
+ const [spec, nodeRuns, latestCheckpoint] = await Promise.all([
669
+ planRunService.getPlanSpecById(run.planSpecId),
670
+ planRunService.listNodeRuns(run.id),
671
+ planRunService.getLatestCheckpoint(run.id),
672
+ ])
673
+ const emittedEvents: PlanEventRecord[] = []
674
+ const checkpointReason = resolution === 'changes-requested' ? 'plan-changes-requested' : 'plan-rejected'
675
+
676
+ await databaseService.withTransaction(async (tx) => {
677
+ const rejectedRun = PlanRunSchema.parse(
678
+ await tx
679
+ .update(ensureRecordId(run.id, TABLES.PLAN_RUN))
680
+ .content(
681
+ toRunData(run, {
682
+ status: 'aborted',
683
+ currentNodeId: null,
684
+ waitingNodeId: null,
685
+ readyNodeIds: [],
686
+ completedAt: new Date(),
687
+ }),
688
+ )
689
+ .output('after'),
690
+ )
691
+
692
+ const event = await tx
693
+ .create(new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7()))
694
+ .content({
695
+ planSpecId: ensureRecordId(spec.id, TABLES.PLAN_SPEC),
696
+ runId: ensureRecordId(rejectedRun.id, TABLES.PLAN_RUN),
697
+ eventType,
698
+ fromStatus: run.status,
699
+ toStatus: rejectedRun.status,
700
+ message:
701
+ resolution === 'changes-requested'
702
+ ? `Requested changes for execution plan "${spec.title}".`
703
+ : `Rejected execution plan "${spec.title}".`,
704
+ emittedBy: params.emittedBy,
705
+ detail: { resolution, ...(params.reason ? { reason: params.reason } : {}) },
706
+ })
707
+ .output('after')
708
+ emittedEvents.push(PlanEventSchema.parse(event))
709
+
710
+ const checkpoint = PlanCheckpointSchema.parse(
711
+ await tx
712
+ .create(new RecordId(TABLES.PLAN_CHECKPOINT, Bun.randomUUIDv7()))
713
+ .content({
714
+ runId: ensureRecordId(rejectedRun.id, TABLES.PLAN_RUN),
715
+ sequence: (latestCheckpoint?.sequence ?? 0) + 1,
716
+ runStatus: rejectedRun.status,
717
+ readyNodeIds: [],
718
+ activeNodeIds: [],
719
+ artifactIds: [],
720
+ lastCompletedNodeIds: nodeRuns
721
+ .filter((nodeRun) => nodeRun.status === 'completed' || nodeRun.status === 'partial')
722
+ .map((nodeRun) => nodeRun.nodeId),
723
+ snapshot: {
724
+ reason: checkpointReason,
725
+ resolution,
726
+ currentNodeId: null,
727
+ waitingNodeId: null,
728
+ readyNodeIds: [],
729
+ },
730
+ })
731
+ .output('after'),
732
+ )
733
+
734
+ const updatedRun = PlanRunSchema.parse(
735
+ await tx
736
+ .update(ensureRecordId(rejectedRun.id, TABLES.PLAN_RUN))
737
+ .content(toRunData(rejectedRun, { lastCheckpointId: checkpoint.id }))
738
+ .output('after'),
739
+ )
740
+
741
+ const checkpointEvent = await tx
742
+ .create(new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7()))
743
+ .content({
744
+ planSpecId: ensureRecordId(spec.id, TABLES.PLAN_SPEC),
745
+ runId: ensureRecordId(updatedRun.id, TABLES.PLAN_RUN),
746
+ eventType: 'checkpoint-saved',
747
+ message: `Saved checkpoint ${(latestCheckpoint?.sequence ?? 0) + 1}.`,
748
+ emittedBy: 'system',
749
+ detail: {
750
+ checkpointId: recordIdToString(checkpoint.id, TABLES.PLAN_CHECKPOINT),
751
+ reason: checkpointReason,
752
+ resolution,
753
+ },
754
+ })
755
+ .output('after')
756
+ emittedEvents.push(PlanEventSchema.parse(checkpointEvent))
757
+ })
758
+
759
+ await planEventDeliveryService.dispatchEvents(emittedEvents)
760
+
761
+ return await planRunService.toSerializablePlan(await planRunService.getRunById(run.id))
762
+ }
763
+
485
764
  async applyApprovalResponseFromMessages(params: {
486
765
  threadId: RecordIdInput
487
766
  approvalMessages: ChatMessage[]
@@ -554,6 +833,23 @@ class ExecutionPlanService {
554
833
  return ownershipDispatcherService.dispatchRunToStableBoundary({ runId: run.id, emittedBy: params.respondedBy })
555
834
  }
556
835
 
836
+ private async serializeRuns(
837
+ runs: PlanRunRecord[],
838
+ options?: Partial<ExecutionPlanQueryArgs>,
839
+ ): Promise<SerializableExecutionPlan[]> {
840
+ return await Promise.all(
841
+ runs.map((run) =>
842
+ planRunService.toSerializablePlan(run, {
843
+ includeEvents: options?.includeEvents,
844
+ includeArtifacts: options?.includeArtifacts,
845
+ includeApprovals: options?.includeApprovals,
846
+ includeCheckpoints: options?.includeCheckpoints,
847
+ includeValidationIssues: options?.includeValidationIssues,
848
+ }),
849
+ ),
850
+ )
851
+ }
852
+
557
853
  private async assertDispatchExecutors(preparedDraft: Parameters<typeof planValidatorService.validateDraft>[0]) {
558
854
  const issues = ownershipDispatcherService.validateDraftExecutors(preparedDraft)
559
855
  if (issues.length > 0) {
@@ -568,6 +864,41 @@ class ExecutionPlanService {
568
864
  return ownershipDispatcherService.dispatchRunToStableBoundary({ runId: params.runId, emittedBy: params.emittedBy })
569
865
  }
570
866
 
867
+ private async attachPlanScheduleIfNeeded(params: {
868
+ organizationId: RecordIdInput
869
+ threadId: RecordIdInput
870
+ runId: RecordIdInput
871
+ planSpecId: RecordIdInput
872
+ }): Promise<PlanRunRecord> {
873
+ const [run, spec] = await Promise.all([
874
+ planRunService.getRunById(params.runId),
875
+ planRunService.getPlanSpecById(params.planSpecId),
876
+ ])
877
+ if (!spec.schedule || run.scheduleId) {
878
+ return run
879
+ }
880
+
881
+ const schedule = await planSchedulerService.createSchedule({
882
+ organizationId: params.organizationId,
883
+ threadId: params.threadId,
884
+ planSpecId: params.planSpecId,
885
+ runId: params.runId,
886
+ scheduleSpec: spec.schedule,
887
+ })
888
+
889
+ const updatedRun = await databaseService.update(
890
+ TABLES.PLAN_RUN,
891
+ ensureRecordId(params.runId, TABLES.PLAN_RUN),
892
+ toRunData(run, {
893
+ scheduleId: schedule.id,
894
+ scheduledAt: schedule.nextFireAt ? toDatabaseDateTime(schedule.nextFireAt) : undefined,
895
+ }),
896
+ PlanRunSchema,
897
+ )
898
+
899
+ return updatedRun ?? run
900
+ }
901
+
571
902
  private async createNodeSpecs(
572
903
  tx: DatabaseTransaction,
573
904
  planSpecId: RecordIdInput,
@@ -657,7 +988,10 @@ class ExecutionPlanService {
657
988
  spec: PlanSpecRecord
658
989
  organizationId: RecordIdInput
659
990
  threadId: RecordIdInput
991
+ sourceThreadId?: RecordIdInput
660
992
  leadAgentId: string
993
+ createdByAgentId?: string
994
+ requireApproval: boolean
661
995
  nodes: CompiledPlanNode[]
662
996
  emittedEvents: PlanEventRecord[]
663
997
  createdEventType: 'plan-created' | 'plan-replaced'
@@ -665,7 +999,7 @@ class ExecutionPlanService {
665
999
  createdEventDetail: Record<string, unknown>
666
1000
  checkpointReason: 'plan-created' | 'plan-replaced'
667
1001
  runPatch?: { replacedRunId?: RecordIdInput }
668
- }): Promise<void> {
1002
+ }): Promise<PlanRunRecord> {
669
1003
  const nodeSpecs = await this.createNodeSpecs(params.tx, params.spec.id, params.nodes)
670
1004
  const run = PlanRunSchema.parse(
671
1005
  await params.tx
@@ -674,29 +1008,33 @@ class ExecutionPlanService {
674
1008
  planSpecId: ensureRecordId(params.spec.id, TABLES.PLAN_SPEC),
675
1009
  organizationId: ensureRecordId(params.organizationId, TABLES.ORGANIZATION),
676
1010
  threadId: ensureRecordId(params.threadId, TABLES.THREAD),
1011
+ ...(params.sourceThreadId ? { sourceThreadId: ensureRecordId(params.sourceThreadId, TABLES.THREAD) } : {}),
677
1012
  leadAgentId: params.leadAgentId,
678
- status: 'running',
1013
+ ...(params.createdByAgentId ? { createdByAgentId: params.createdByAgentId } : {}),
1014
+ status: params.requireApproval ? 'pending-approval' : 'running',
679
1015
  readyNodeIds: [],
680
1016
  failureCount: 0,
681
1017
  ...(params.runPatch?.replacedRunId
682
1018
  ? { replacedRunId: ensureRecordId(params.runPatch.replacedRunId, TABLES.PLAN_RUN) }
683
1019
  : {}),
684
- startedAt: new Date(),
1020
+ ...(params.requireApproval ? {} : { startedAt: new Date() }),
685
1021
  })
686
1022
  .output('after'),
687
1023
  )
688
1024
 
689
1025
  const nodeRuns = await this.createNodeRuns(params.tx, run.id, params.spec.id, nodeSpecs)
690
- const synced = await planExecutorService.syncRunGraph({
691
- tx: params.tx,
692
- run,
693
- spec: params.spec,
694
- nodeSpecs,
695
- nodeRuns,
696
- artifacts: [],
697
- emittedBy: params.leadAgentId,
698
- capturedEvents: params.emittedEvents,
699
- })
1026
+ const synced = params.requireApproval
1027
+ ? { run, nodeRuns }
1028
+ : await planExecutorService.syncRunGraph({
1029
+ tx: params.tx,
1030
+ run,
1031
+ spec: params.spec,
1032
+ nodeSpecs,
1033
+ nodeRuns,
1034
+ artifacts: [],
1035
+ emittedBy: params.leadAgentId,
1036
+ capturedEvents: params.emittedEvents,
1037
+ })
700
1038
 
701
1039
  const event = await params.tx
702
1040
  .create(new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7()))
@@ -711,6 +1049,22 @@ class ExecutionPlanService {
711
1049
  .output('after')
712
1050
  params.emittedEvents.push(PlanEventSchema.parse(event))
713
1051
 
1052
+ if (params.requireApproval) {
1053
+ const pendingApprovalEvent = await params.tx
1054
+ .create(new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7()))
1055
+ .content({
1056
+ planSpecId: ensureRecordId(params.spec.id, TABLES.PLAN_SPEC),
1057
+ runId: ensureRecordId(synced.run.id, TABLES.PLAN_RUN),
1058
+ eventType: 'plan-pending-approval',
1059
+ toStatus: synced.run.status,
1060
+ message: `Execution plan "${params.spec.title}" is pending approval.`,
1061
+ emittedBy: params.leadAgentId,
1062
+ detail: { ...params.createdEventDetail, nodeCount: nodeSpecs.length },
1063
+ })
1064
+ .output('after')
1065
+ params.emittedEvents.push(PlanEventSchema.parse(pendingApprovalEvent))
1066
+ }
1067
+
714
1068
  const checkpoint = PlanCheckpointSchema.parse(
715
1069
  await params.tx
716
1070
  .create(new RecordId(TABLES.PLAN_CHECKPOINT, Bun.randomUUIDv7()))
@@ -756,6 +1110,8 @@ class ExecutionPlanService {
756
1110
  })
757
1111
  .output('after')
758
1112
  params.emittedEvents.push(PlanEventSchema.parse(checkpointEvent))
1113
+
1114
+ return updatedRun
759
1115
  }
760
1116
  }
761
1117
 
@@ -16,7 +16,7 @@ function formatDispatchError(error: unknown): string {
16
16
  return error instanceof Error ? error.message : String(error)
17
17
  }
18
18
 
19
- const STABLE_RUN_STATUSES = new Set(['awaiting-human', 'blocked', 'failed', 'completed', 'aborted'])
19
+ const STABLE_RUN_STATUSES = new Set(['pending-approval', 'awaiting-human', 'blocked', 'failed', 'completed', 'aborted'])
20
20
 
21
21
  class GlobalOrchestratorService {
22
22
  detectConvergence(params: {
@@ -34,7 +34,7 @@ import { systemExecutorService } from './system-executor.service'
34
34
  import { ThreadSchema } from './thread.types'
35
35
  import { userService } from './user.service'
36
36
 
37
- const STABLE_RUN_STATUSES = new Set(['awaiting-human', 'blocked', 'failed', 'completed', 'aborted'])
37
+ const STABLE_RUN_STATUSES = new Set(['pending-approval', 'awaiting-human', 'blocked', 'failed', 'completed', 'aborted'])
38
38
  const MAX_DISPATCH_ITERATIONS = 64
39
39
 
40
40
  function toPlanNodeSpec(nodeSpec: PlanNodeSpecRecord): PlanNodeSpec {
@@ -1,4 +1,9 @@
1
- import type { ExecutionPlanToolResultData, PlanRunRecord, SerializableExecutionPlan } from '@lota-sdk/shared'
1
+ import type {
2
+ ExecutionPlanToolPlanSummary,
3
+ ExecutionPlanToolResultData,
4
+ PlanRunRecord,
5
+ SerializableExecutionPlan,
6
+ } from '@lota-sdk/shared'
2
7
 
3
8
  import type { RecordIdInput } from '../db/record-id'
4
9
  import { ensureRecordId } from '../db/record-id'
@@ -7,12 +12,25 @@ import { toDatabaseDateTime } from '../utils/date-time'
7
12
 
8
13
  export type PlanRunUpdate = Omit<
9
14
  Partial<PlanRunRecord>,
10
- 'currentNodeId' | 'waitingNodeId' | 'replacedRunId' | 'lastCheckpointId' | 'startedAt' | 'completedAt'
15
+ | 'sourceThreadId'
16
+ | 'createdByAgentId'
17
+ | 'currentNodeId'
18
+ | 'waitingNodeId'
19
+ | 'replacedRunId'
20
+ | 'lastCheckpointId'
21
+ | 'scheduledAt'
22
+ | 'scheduleId'
23
+ | 'startedAt'
24
+ | 'completedAt'
11
25
  > & {
26
+ sourceThreadId?: RecordIdInput | null
27
+ createdByAgentId?: string | null
12
28
  currentNodeId?: string | null
13
29
  waitingNodeId?: string | null
14
30
  replacedRunId?: RecordIdInput | null
15
31
  lastCheckpointId?: RecordIdInput | null
32
+ scheduledAt?: string | Date | null
33
+ scheduleId?: RecordIdInput | null
16
34
  startedAt?: string | Date | null
17
35
  completedAt?: string | Date | null
18
36
  }
@@ -22,7 +40,21 @@ export function toRunData(run: PlanRunRecord, patch: PlanRunUpdate) {
22
40
  planSpecId: ensureRecordId(run.planSpecId, TABLES.PLAN_SPEC),
23
41
  organizationId: ensureRecordId(run.organizationId, TABLES.ORGANIZATION),
24
42
  threadId: ensureRecordId(run.threadId, TABLES.THREAD),
43
+ ...(patch.sourceThreadId === null
44
+ ? {}
45
+ : patch.sourceThreadId !== undefined
46
+ ? { sourceThreadId: ensureRecordId(patch.sourceThreadId, TABLES.THREAD) }
47
+ : run.sourceThreadId
48
+ ? { sourceThreadId: ensureRecordId(run.sourceThreadId, TABLES.THREAD) }
49
+ : {}),
25
50
  leadAgentId: patch.leadAgentId ?? run.leadAgentId,
51
+ ...(patch.createdByAgentId === null
52
+ ? {}
53
+ : patch.createdByAgentId !== undefined
54
+ ? { createdByAgentId: patch.createdByAgentId }
55
+ : run.createdByAgentId
56
+ ? { createdByAgentId: run.createdByAgentId }
57
+ : {}),
26
58
  status: patch.status ?? run.status,
27
59
  ...(patch.currentNodeId === null
28
60
  ? {}
@@ -54,6 +86,20 @@ export function toRunData(run: PlanRunRecord, patch: PlanRunUpdate) {
54
86
  : run.lastCheckpointId
55
87
  ? { lastCheckpointId: ensureRecordId(run.lastCheckpointId, TABLES.PLAN_CHECKPOINT) }
56
88
  : {}),
89
+ ...(patch.scheduledAt === null
90
+ ? {}
91
+ : patch.scheduledAt !== undefined
92
+ ? { scheduledAt: toDatabaseDateTime(patch.scheduledAt) }
93
+ : run.scheduledAt
94
+ ? { scheduledAt: toDatabaseDateTime(run.scheduledAt) }
95
+ : {}),
96
+ ...(patch.scheduleId === null
97
+ ? {}
98
+ : patch.scheduleId
99
+ ? { scheduleId: ensureRecordId(patch.scheduleId, TABLES.PLAN_SCHEDULE) }
100
+ : run.scheduleId
101
+ ? { scheduleId: ensureRecordId(run.scheduleId, TABLES.PLAN_SCHEDULE) }
102
+ : {}),
57
103
  ...(patch.startedAt === null
58
104
  ? {}
59
105
  : patch.startedAt !== undefined
@@ -71,13 +117,17 @@ export function toRunData(run: PlanRunRecord, patch: PlanRunUpdate) {
71
117
  }
72
118
  }
73
119
 
74
- function toSlimPlanSummary(plan: SerializableExecutionPlan): NonNullable<ExecutionPlanToolResultData['plan']> {
120
+ export function toToolPlanSummary(plan: SerializableExecutionPlan): ExecutionPlanToolPlanSummary {
75
121
  const completed = plan.progress.completed + plan.progress.partial
76
122
  return {
77
123
  runId: plan.runId,
124
+ threadId: plan.threadId,
78
125
  title: plan.title,
79
126
  objective: plan.objective,
80
127
  status: plan.status,
128
+ leadAgentId: plan.leadAgentId,
129
+ ...(plan.sourceThreadId ? { sourceThreadId: plan.sourceThreadId } : {}),
130
+ ...(plan.createdByAgentId ? { createdByAgentId: plan.createdByAgentId } : {}),
81
131
  progress: { completed, total: plan.progress.total },
82
132
  nodes: plan.nodes.map((node) => ({
83
133
  id: node.id,
@@ -85,7 +135,23 @@ function toSlimPlanSummary(plan: SerializableExecutionPlan): NonNullable<Executi
85
135
  type: node.type,
86
136
  status: node.status,
87
137
  ownerRef: node.owner.ref,
138
+ objective: node.objective,
139
+ ownerType: node.owner.executorType,
140
+ artifactCount: plan.artifacts.filter((artifact) => artifact.nodeId === node.id).length,
141
+ approvalId:
142
+ plan.approvals.find((approval) => approval.nodeId === node.id && approval.status === 'pending')?.id ?? null,
143
+ approvalStatus:
144
+ plan.approvals.find((approval) => approval.nodeId === node.id && approval.status === 'pending')?.status ?? null,
145
+ blockedReason: node.blockedReason ?? null,
146
+ latestNotes: node.latestNotes ?? null,
147
+ startedAt: node.startedAt ?? null,
148
+ completedAt: node.completedAt ?? null,
149
+ readyAt: node.readyAt ?? null,
150
+ deliverableNames: node.deliverables.map((deliverable) => deliverable.name),
151
+ upstreamNodeIds: [...node.upstreamNodeIds],
152
+ downstreamNodeIds: [...node.downstreamNodeIds],
88
153
  })),
154
+ edges: [...plan.edges],
89
155
  activeNodeIds: plan.activeNodeIds,
90
156
  readyNodeIds: plan.readyNodeIds,
91
157
  }
@@ -96,7 +162,7 @@ export function buildExecutionPlanToolResult(params: {
96
162
  plan: SerializableExecutionPlan | null
97
163
  message: string
98
164
  }): ExecutionPlanToolResultData {
99
- const slim = params.plan ? toSlimPlanSummary(params.plan) : null
165
+ const slim = params.plan ? toToolPlanSummary(params.plan) : null
100
166
  return {
101
167
  action: params.action,
102
168
  message: params.message,
@@ -37,7 +37,7 @@ import { databaseService } from '../db/service'
37
37
  import { TABLES } from '../db/tables'
38
38
  import { toIsoDateTimeString, toOptionalIsoDateTimeString } from '../utils/date-time'
39
39
 
40
- const ACTIVE_RUN_STATUSES = new Set(['running', 'awaiting-human', 'blocked'])
40
+ const ACTIVE_RUN_STATUSES = new Set(['pending-approval', 'running', 'awaiting-human', 'blocked'])
41
41
 
42
42
  function buildProgress(nodeRuns: PlanNodeRunRecord[]) {
43
43
  const counts = nodeRuns.reduce(
@@ -239,6 +239,31 @@ class PlanRunService {
239
239
  return runs.filter((run) => ACTIVE_RUN_STATUSES.has(run.status))
240
240
  }
241
241
 
242
+ async getRunsCreatedInContext(params: {
243
+ organizationId: RecordIdInput
244
+ sourceThreadId?: RecordIdInput
245
+ createdByAgentId?: string
246
+ statuses?: ReadonlyArray<PlanRunRecord['status']>
247
+ }): Promise<PlanRunRecord[]> {
248
+ const filter: Record<string, unknown> = {
249
+ organizationId: ensureRecordId(params.organizationId, TABLES.ORGANIZATION),
250
+ }
251
+ if (params.sourceThreadId) {
252
+ filter.sourceThreadId = ensureRecordId(params.sourceThreadId, TABLES.THREAD)
253
+ }
254
+ if (params.createdByAgentId) {
255
+ filter.createdByAgentId = params.createdByAgentId
256
+ }
257
+
258
+ const runs = await databaseService.findMany(TABLES.PLAN_RUN, filter, PlanRunSchema, {
259
+ orderBy: 'updatedAt',
260
+ orderDir: 'DESC',
261
+ })
262
+
263
+ const statuses = params.statuses ? new Set(params.statuses) : ACTIVE_RUN_STATUSES
264
+ return runs.filter((run) => statuses.has(run.status))
265
+ }
266
+
242
267
  async listNodeRuns(runId: RecordIdInput): Promise<PlanNodeRunRecord[]> {
243
268
  return databaseService.findMany(
244
269
  TABLES.PLAN_NODE_RUN,
@@ -435,12 +460,14 @@ class PlanRunService {
435
460
  specId: recordIdToString(spec.id, TABLES.PLAN_SPEC),
436
461
  runId: recordIdToString(run.id, TABLES.PLAN_RUN),
437
462
  threadId: recordIdToString(run.threadId, TABLES.THREAD),
463
+ sourceThreadId: run.sourceThreadId ? recordIdToString(run.sourceThreadId, TABLES.THREAD) : undefined,
438
464
  organizationId: recordIdToString(run.organizationId, TABLES.ORGANIZATION),
439
465
  title: spec.title,
440
466
  objective: spec.objective,
441
467
  version: spec.version,
442
468
  status: run.status,
443
469
  leadAgentId: run.leadAgentId,
470
+ createdByAgentId: run.createdByAgentId,
444
471
  defaultExecutionVisibility: spec.defaultExecutionVisibility,
445
472
  executionMode: spec.executionMode,
446
473
  schemaRegistry: slim ? {} : structuredClone(spec.schemaRegistry),
@@ -143,7 +143,10 @@ class PlanTemplateService {
143
143
  templateId: RecordIdInput
144
144
  organizationId: RecordIdInput
145
145
  threadId: RecordIdInput
146
+ sourceThreadId?: RecordIdInput
146
147
  leadAgentId: string
148
+ createdByAgentId?: string
149
+ requireApproval?: boolean
147
150
  overrides?: Partial<PlanDraft>
148
151
  carryForwardArtifacts?: PlanArtifactRecord[]
149
152
  }): Promise<ExecutionPlanToolResultData> {
@@ -162,7 +165,10 @@ class PlanTemplateService {
162
165
  return executionPlanService.createPlan({
163
166
  organizationId: params.organizationId,
164
167
  threadId: params.threadId,
168
+ sourceThreadId: params.sourceThreadId,
165
169
  leadAgentId: params.leadAgentId,
170
+ createdByAgentId: params.createdByAgentId,
171
+ requireApproval: params.requireApproval,
166
172
  input: draft,
167
173
  })
168
174
  }
@@ -56,10 +56,16 @@ export function createExecutionPlanTool(params: {
56
56
  case 'create': {
57
57
  const draft = extractAgentPlanDraft(parsed)
58
58
  params.validateInlinePlan?.(draft)
59
+ const targetThreadId = parsed.targetThreadId ?? params.threadId
60
+ const isCrossThreadTarget =
61
+ recordIdToString(targetThreadId, TABLES.THREAD) !== recordIdToString(params.threadId, TABLES.THREAD)
59
62
  result = await resolvedEpService.createPlan({
60
63
  organizationId: params.orgId,
61
- threadId: parsed.targetThreadId ?? params.threadId,
64
+ threadId: targetThreadId,
65
+ ...(isCrossThreadTarget ? { sourceThreadId: params.threadId } : {}),
62
66
  leadAgentId: params.agentId,
67
+ createdByAgentId: params.agentId,
68
+ requireApproval: parsed.requireApproval ?? isCrossThreadTarget,
63
69
  input: draft,
64
70
  })
65
71
  break
@@ -101,11 +107,15 @@ export function createExecutionPlanTool(params: {
101
107
  const created = await resolvedEpService.createPlan({
102
108
  organizationId: params.orgId,
103
109
  threadId: targetThread.id,
110
+ sourceThreadId: params.threadId,
104
111
  leadAgentId: params.agentId,
112
+ createdByAgentId: params.agentId,
113
+ requireApproval: parsed.requireApproval ?? true,
105
114
  input: draft,
106
115
  })
107
116
  result = {
108
117
  ...created,
118
+ runId: created.plan?.runId ?? '',
109
119
  threadId: targetThread.id,
110
120
  threadTitle: targetThread.title,
111
121
  createdThread,
@@ -125,7 +135,8 @@ export function createExecutionPlanTool(params: {
125
135
  organizationId: params.orgId,
126
136
  threadId: params.threadId,
127
137
  leadAgentId: params.agentId,
128
- input: { runId: parsed.runId, reason: parsed.reason, ...draft },
138
+ createdByAgentId: params.agentId,
139
+ input: { runId: parsed.runId, reason: parsed.reason, requireApproval: parsed.requireApproval, ...draft },
129
140
  })
130
141
  break
131
142
  }
@@ -1,6 +1,7 @@
1
1
  export * from './execution-plan.tool'
2
2
  export * from './fetch-webpage.tool'
3
3
  export * from './memory-block.tool'
4
+ export * from './plan-approval.tool'
4
5
  export * from './read-file-parts.tool'
5
6
  export * from './remember-memory.tool'
6
7
  export * from './research-topic.tool'
@@ -0,0 +1,66 @@
1
+ import { PlanApprovalArgsSchema, PlanApprovalToolResultDataSchema } from '@lota-sdk/shared'
2
+ import { tool } from 'ai'
3
+
4
+ import type { RecordIdRef } from '../db/record-id'
5
+ import { executionPlanService } from '../services/execution-plan.service'
6
+ import { toToolPlanSummary } from '../services/plan-run-data'
7
+
8
+ type PlanApprovalExecutionPlanService = Pick<typeof executionPlanService, 'approvePlan' | 'rejectPlan'>
9
+
10
+ export function createPlanApprovalTool(params: {
11
+ orgId: RecordIdRef
12
+ threadId: RecordIdRef
13
+ actorId: string
14
+ executionPlanService?: PlanApprovalExecutionPlanService
15
+ }) {
16
+ const resolvedExecutionPlanService = params.executionPlanService ?? executionPlanService
17
+
18
+ return tool({
19
+ description:
20
+ 'Approve, reject, or request changes to a plan that is pending approval. Use approve to start execution, reject to abort it, or modify to request a revised plan.',
21
+ inputSchema: PlanApprovalArgsSchema,
22
+ outputSchema: PlanApprovalToolResultDataSchema,
23
+ execute: async (input) => {
24
+ switch (input.action) {
25
+ case 'approve': {
26
+ const plan = await resolvedExecutionPlanService.approvePlan({
27
+ organizationId: params.orgId,
28
+ threadId: params.threadId,
29
+ runId: input.runId,
30
+ emittedBy: params.actorId,
31
+ })
32
+ const toolPlan = toToolPlanSummary(plan)
33
+ return {
34
+ action: 'approved',
35
+ message: `Approved execution plan "${plan.title}" and started it.`,
36
+ hasPlan: true,
37
+ status: toolPlan.status,
38
+ plan: toolPlan,
39
+ }
40
+ }
41
+ case 'reject':
42
+ case 'modify': {
43
+ const plan = await resolvedExecutionPlanService.rejectPlan({
44
+ organizationId: params.orgId,
45
+ threadId: params.threadId,
46
+ runId: input.runId,
47
+ emittedBy: params.actorId,
48
+ reason: input.reason,
49
+ resolution: input.action === 'modify' ? 'changes-requested' : 'rejected',
50
+ })
51
+ const toolPlan = toToolPlanSummary(plan)
52
+ return {
53
+ action: input.action === 'modify' ? 'changes-requested' : 'rejected',
54
+ message:
55
+ input.action === 'modify'
56
+ ? `Requested changes for execution plan "${plan.title}".`
57
+ : `Rejected execution plan "${plan.title}".`,
58
+ hasPlan: true,
59
+ status: toolPlan.status,
60
+ plan: toolPlan,
61
+ }
62
+ }
63
+ }
64
+ },
65
+ })
66
+ }