@lota-sdk/core 0.4.3 → 0.4.5

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.
@@ -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,5 +1,6 @@
1
1
  import type { PlanExecutionVisibility, PlanNodeSpecRecord, PlanRunRecord, PlanSpecRecord } from '@lota-sdk/shared'
2
2
  import { PlanRunSchema } from '@lota-sdk/shared'
3
+ import { BoundQuery } from 'surrealdb'
3
4
 
4
5
  import type { RecordIdInput } from '../db/record-id'
5
6
  import { ensureRecordId, recordIdToString } from '../db/record-id'
@@ -254,10 +255,10 @@ class PlanAgentQueryService {
254
255
 
255
256
  const whereOrganization = organizationId ? ' AND organizationId = $organizationId' : ''
256
257
  return databaseService.queryMany(
257
- {
258
- query: `SELECT * FROM ${TABLES.PLAN_RUN} WHERE status INSIDE $statuses${whereOrganization} ORDER BY updatedAt DESC`,
258
+ new BoundQuery(
259
+ `SELECT * FROM ${TABLES.PLAN_RUN} WHERE status INSIDE $statuses${whereOrganization} ORDER BY updatedAt DESC`,
259
260
  bindings,
260
- },
261
+ ),
261
262
  PlanRunSchema,
262
263
  )
263
264
  }
@@ -18,10 +18,6 @@ function buildImplicitLinearEdges(draft: PlanDraft) {
18
18
  }
19
19
 
20
20
  class PlanBuilderService {
21
- roleAssignment(draft: PlanDraft): PlanDraft {
22
- return draft
23
- }
24
-
25
21
  structureDesign(draft: PlanDraft): PlanDraft {
26
22
  return {
27
23
  ...draft,
@@ -63,8 +59,7 @@ class PlanBuilderService {
63
59
  }
64
60
 
65
61
  prepareDraft(draft: PlanDraft): PlanDraft {
66
- const withRoles = this.roleAssignment(draft)
67
- const withStructure = this.structureDesign(withRoles)
62
+ const withStructure = this.structureDesign(draft)
68
63
  return this.semanticCompletion(withStructure)
69
64
  }
70
65
  }
@@ -8,7 +8,7 @@ import type {
8
8
  PlanRunRecord,
9
9
  } from '@lota-sdk/shared'
10
10
  import { PlanEventSchema, PlanNodeRunSchema, PlanNodeSpecRecordSchema, PlanRunSchema } from '@lota-sdk/shared'
11
- import { RecordId } from 'surrealdb'
11
+ import { BoundQuery, RecordId } from 'surrealdb'
12
12
 
13
13
  import type { RecordIdInput } from '../db/record-id'
14
14
  import { ensureRecordId, recordIdToString } from '../db/record-id'
@@ -185,10 +185,9 @@ class PlanDeadlineService {
185
185
  entries: Array<{ nodeRun: PlanNodeRunRecord; nodeSpec: PlanNodeSpecRecord; evaluation: DeadlineEvaluationResult }>
186
186
  }> {
187
187
  const activeNodeRuns = await databaseService.queryMany(
188
- {
189
- query: `SELECT * FROM ${TABLES.PLAN_NODE_RUN} WHERE status IN $statuses`,
190
- bindings: { statuses: ['running', 'awaiting-human'] },
191
- },
188
+ new BoundQuery(`SELECT * FROM ${TABLES.PLAN_NODE_RUN} WHERE status IN $statuses`, {
189
+ statuses: ['running', 'awaiting-human'],
190
+ }),
192
191
  PlanNodeRunSchema,
193
192
  )
194
193
 
@@ -380,20 +379,20 @@ class PlanDeadlineService {
380
379
 
381
380
  if (dedupeKey) {
382
381
  const existing = await databaseService.queryMany(
383
- {
384
- query: `SELECT * FROM ${TABLES.PLAN_EVENT}
382
+ new BoundQuery(
383
+ `SELECT * FROM ${TABLES.PLAN_EVENT}
385
384
  WHERE runId = $runId
386
385
  AND nodeId = $nodeId
387
386
  AND eventType = $eventType
388
387
  AND detail.dedupeKey = $dedupeKey
389
388
  LIMIT 1`,
390
- bindings: {
389
+ {
391
390
  runId: ensureRecordId(params.run.id, TABLES.PLAN_RUN),
392
391
  nodeId: params.nodeRun.nodeId,
393
392
  eventType: params.eventType,
394
393
  dedupeKey,
395
394
  },
396
- },
395
+ ),
397
396
  PlanEventSchema,
398
397
  )
399
398
  if (existing.length > 0) {