@lota-sdk/core 0.1.20 → 0.1.22

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 (32) hide show
  1. package/infrastructure/schema/02_execution_plan.surql +4 -0
  2. package/package.json +6 -6
  3. package/src/ai-gateway/ai-gateway.ts +2 -4
  4. package/src/create-runtime.ts +8 -0
  5. package/src/queues/document-processor.queue.ts +11 -8
  6. package/src/queues/index.ts +1 -0
  7. package/src/queues/plan-agent-heartbeat.queue.ts +100 -0
  8. package/src/queues/queue-factory.ts +12 -11
  9. package/src/redis/redis-lease-lock.ts +1 -1
  10. package/src/runtime/agent-runtime-policy.ts +41 -4
  11. package/src/runtime/execution-plan-visibility.ts +23 -0
  12. package/src/runtime/execution-plan.ts +1 -0
  13. package/src/runtime/runtime-extensions.ts +26 -0
  14. package/src/runtime/runtime-worker-registry.ts +9 -1
  15. package/src/services/agent-executor.service.ts +6 -0
  16. package/src/services/execution-plan.service.ts +51 -36
  17. package/src/services/index.ts +3 -0
  18. package/src/services/ownership-dispatcher.service.ts +50 -8
  19. package/src/services/plan-agent-heartbeat.service.ts +136 -0
  20. package/src/services/plan-agent-query.service.ts +238 -0
  21. package/src/services/plan-builder.service.ts +11 -1
  22. package/src/services/plan-compiler.service.ts +2 -0
  23. package/src/services/plan-deadline.service.ts +186 -44
  24. package/src/services/plan-event-delivery.service.ts +170 -0
  25. package/src/services/plan-executor.service.ts +107 -3
  26. package/src/services/plan-helpers.ts +13 -0
  27. package/src/services/plan-run.service.ts +4 -0
  28. package/src/services/plan-template.service.ts +0 -1
  29. package/src/services/workstream-turn-preparation.service.ts +452 -176
  30. package/src/services/workstream-turn.ts +101 -1
  31. package/src/services/workstream.service.ts +76 -16
  32. package/src/tools/execution-plan.tool.ts +0 -2
@@ -0,0 +1,170 @@
1
+ import { PlanEventSchema } from '@lota-sdk/shared'
2
+ import type { PlanEventRecord } from '@lota-sdk/shared'
3
+
4
+ import { ensureRecordId, recordIdToString } from '../db/record-id'
5
+ import { databaseService } from '../db/service'
6
+ import { TABLES } from '../db/tables'
7
+ import { getRedisConnection } from '../redis'
8
+ import { resolvePlanNodeExecutionVisibility } from '../runtime/execution-plan-visibility'
9
+ import { getRuntimeAdapters } from '../runtime/runtime-extensions'
10
+ import type { LotaRuntimePlanEventEnvelope } from '../runtime/runtime-extensions'
11
+ import { planRunService } from './plan-run.service'
12
+ import { userService } from './user.service'
13
+ import { WorkstreamSchema } from './workstream.types'
14
+
15
+ const PLAN_EVENT_DELIVERED_TTL_MS = 7 * 24 * 60 * 60 * 1000
16
+
17
+ function buildDeliveredKey(eventId: string): string {
18
+ return `plan-event:delivered:${eventId}`
19
+ }
20
+
21
+ class PlanEventDeliveryService {
22
+ async dispatchEvents(events: PlanEventRecord[]): Promise<void> {
23
+ const runIds = new Set(events.map((event) => recordIdToString(event.runId, TABLES.PLAN_RUN)))
24
+ for (const runId of runIds) {
25
+ await this.dispatchUndeliveredEvents(runId)
26
+ }
27
+ }
28
+
29
+ async dispatchEvent(rawEvent: PlanEventRecord): Promise<void> {
30
+ const event = PlanEventSchema.parse(rawEvent)
31
+ const eventId = recordIdToString(event.id, TABLES.PLAN_EVENT)
32
+ const deliveredKey = buildDeliveredKey(eventId)
33
+ if (await getRedisConnection().exists(deliveredKey)) {
34
+ return
35
+ }
36
+
37
+ await this.deliverEvent(event)
38
+ await getRedisConnection().set(deliveredKey, '1', 'PX', PLAN_EVENT_DELIVERED_TTL_MS)
39
+ }
40
+
41
+ private async deliverEvent(rawEvent: PlanEventRecord): Promise<void> {
42
+ const event = PlanEventSchema.parse(rawEvent)
43
+ const run = await planRunService.getRunById(event.runId)
44
+ const [spec, nodeSpecs, nodeRuns, workstream] = await Promise.all([
45
+ planRunService.getPlanSpecById(run.planSpecId),
46
+ planRunService.listNodeSpecs(run.planSpecId),
47
+ planRunService.listNodeRuns(run.id),
48
+ databaseService.findOne(
49
+ TABLES.WORKSTREAM,
50
+ { id: ensureRecordId(run.workstreamId, TABLES.WORKSTREAM) },
51
+ WorkstreamSchema,
52
+ ),
53
+ ])
54
+ const nodeSpecsById = new Map(nodeSpecs.map((nodeSpec) => [nodeSpec.nodeId, nodeSpec]))
55
+ const nodeRunsById = new Map(nodeRuns.map((nodeRun) => [nodeRun.nodeId, nodeRun]))
56
+ const organizationId = recordIdToString(run.organizationId, TABLES.ORGANIZATION)
57
+ const workstreamId = recordIdToString(run.workstreamId, TABLES.WORKSTREAM)
58
+ const runIdString = recordIdToString(run.id, TABLES.PLAN_RUN)
59
+ const planSpecId = recordIdToString(spec.id, TABLES.PLAN_SPEC)
60
+ const userId = workstream?.userId
61
+ ? recordIdToString(ensureRecordId(workstream.userId, TABLES.USER), TABLES.USER)
62
+ : undefined
63
+ const userName =
64
+ userId === undefined
65
+ ? undefined
66
+ : await userService
67
+ .getUser(userId)
68
+ .then((user) => user.name)
69
+ .catch(() => undefined)
70
+
71
+ const envelope: LotaRuntimePlanEventEnvelope = {
72
+ event,
73
+ spec,
74
+ run,
75
+ ...(event.nodeId ? { nodeSpec: nodeSpecsById.get(event.nodeId) } : {}),
76
+ ...(event.nodeId ? { nodeRun: nodeRunsById.get(event.nodeId) } : {}),
77
+ organizationId,
78
+ workstreamId,
79
+ runId: runIdString,
80
+ planSpecId,
81
+ ...(userId ? { userId } : {}),
82
+ ...(userName ? { userName } : {}),
83
+ }
84
+
85
+ await this.dispatchAdapterEvent(envelope)
86
+ await this.enqueueWakeIfNeeded(envelope, nodeSpecsById)
87
+ }
88
+
89
+ async dispatchUndeliveredEvents(runId: string, options?: { limit?: number }): Promise<void> {
90
+ const run = await planRunService.getRunById(runId)
91
+ const events = await planRunService.listEvents(run.id, options?.limit ?? 200)
92
+
93
+ for (const rawEvent of events) {
94
+ const event = PlanEventSchema.parse(rawEvent)
95
+ const eventId = recordIdToString(event.id, TABLES.PLAN_EVENT)
96
+ const deliveredKey = buildDeliveredKey(eventId)
97
+ if (await getRedisConnection().exists(deliveredKey)) {
98
+ continue
99
+ }
100
+
101
+ await this.dispatchEvent(event)
102
+ }
103
+ }
104
+
105
+ private async dispatchAdapterEvent(envelope: LotaRuntimePlanEventEnvelope): Promise<void> {
106
+ const adapter = getRuntimeAdapters().events?.planEventAdapter
107
+ if (!adapter) {
108
+ return
109
+ }
110
+
111
+ await adapter.onPlanEvent(envelope)
112
+ }
113
+
114
+ private async enqueueWakeIfNeeded(
115
+ envelope: LotaRuntimePlanEventEnvelope,
116
+ nodeSpecsById: Map<string, NonNullable<LotaRuntimePlanEventEnvelope['nodeSpec']>>,
117
+ ): Promise<void> {
118
+ const wakeTarget = this.resolveWakeTarget(envelope, nodeSpecsById)
119
+ if (!wakeTarget) {
120
+ return
121
+ }
122
+
123
+ const { enqueuePlanAgentHeartbeatWake } = await import('../queues/plan-agent-heartbeat.queue')
124
+ await enqueuePlanAgentHeartbeatWake({
125
+ organizationId: envelope.organizationId,
126
+ workstreamId: envelope.workstreamId,
127
+ runId: envelope.runId,
128
+ nodeId: wakeTarget.nodeId,
129
+ agentId: wakeTarget.agentId,
130
+ reason: wakeTarget.reason,
131
+ })
132
+ }
133
+
134
+ private resolveWakeTarget(
135
+ envelope: LotaRuntimePlanEventEnvelope,
136
+ nodeSpecsById: Map<string, NonNullable<LotaRuntimePlanEventEnvelope['nodeSpec']>>,
137
+ ): { nodeId: string; agentId: string; reason: string } | null {
138
+ const currentNodeId = envelope.run.currentNodeId ?? envelope.event.nodeId
139
+ if (!currentNodeId) {
140
+ return null
141
+ }
142
+
143
+ const effectiveNodeSpec =
144
+ (envelope.event.nodeId === currentNodeId ? envelope.nodeSpec : undefined) ?? nodeSpecsById.get(currentNodeId)
145
+ if (!effectiveNodeSpec || effectiveNodeSpec.owner.executorType !== 'agent') {
146
+ return null
147
+ }
148
+
149
+ const isVisible = resolvePlanNodeExecutionVisibility(envelope.spec, effectiveNodeSpec) === 'visible'
150
+ if (!isVisible) {
151
+ return null
152
+ }
153
+
154
+ const supportedEventTypes = new Set([
155
+ 'node-ready',
156
+ 'node-unblocked',
157
+ 'approval-resolved',
158
+ 'run-resumed',
159
+ 'deadline-warning',
160
+ 'escalation-triggered',
161
+ ])
162
+ if (!supportedEventTypes.has(envelope.event.eventType)) {
163
+ return null
164
+ }
165
+
166
+ return { nodeId: currentNodeId, agentId: effectiveNodeSpec.owner.ref, reason: envelope.event.eventType }
167
+ }
168
+ }
169
+
170
+ export const planEventDeliveryService = new PlanEventDeliveryService()
@@ -35,7 +35,8 @@ import { planApprovalService } from './plan-approval.service'
35
35
  import { planArtifactService } from './plan-artifact.service'
36
36
  import { planCheckpointService } from './plan-checkpoint.service'
37
37
  import { planCoordinationService } from './plan-coordination.service'
38
- import { readPathValue } from './plan-helpers'
38
+ import { planEventDeliveryService } from './plan-event-delivery.service'
39
+ import { isExecutableConditionExpression, readPathValue } from './plan-helpers'
39
40
  import { planRunService } from './plan-run.service'
40
41
  import { planSchedulerService } from './plan-scheduler.service'
41
42
  import type { PlanValidationIssueInput } from './plan-validator.service'
@@ -117,6 +118,9 @@ function evaluateCondition(expression: string | undefined, context: Record<strin
117
118
 
118
119
  const match = normalized.match(/^([a-zA-Z0-9_.]+)\s*(==|!=|>=|<=|>|<)\s*(.+)$/)
119
120
  if (!match) {
121
+ if (!isExecutableConditionExpression(normalized)) {
122
+ return true
123
+ }
120
124
  return Boolean(readPathValue(context, normalized))
121
125
  }
122
126
 
@@ -164,6 +168,7 @@ type PlanNodeRunUpdate = Omit<
164
168
  | 'resolvedInput'
165
169
  | 'latestStructuredOutput'
166
170
  | 'latestNotes'
171
+ | 'handoffContext'
167
172
  | 'latestAttemptId'
168
173
  | 'scheduledAt'
169
174
  | 'readyAt'
@@ -175,6 +180,7 @@ type PlanNodeRunUpdate = Omit<
175
180
  resolvedInput?: Record<string, unknown> | null
176
181
  latestStructuredOutput?: Record<string, unknown> | null
177
182
  latestNotes?: string | null
183
+ handoffContext?: Record<string, unknown> | null
178
184
  latestAttemptId?: RecordIdInput | null
179
185
  scheduledAt?: string | Date | null
180
186
  readyAt?: string | Date | null
@@ -265,6 +271,13 @@ function toNodeRunData(nodeRun: PlanNodeRunRecord, patch: PlanNodeRunUpdate) {
265
271
  : nodeRun.latestNotes
266
272
  ? { latestNotes: nodeRun.latestNotes }
267
273
  : {}),
274
+ ...(patch.handoffContext === null
275
+ ? {}
276
+ : patch.handoffContext !== undefined
277
+ ? { handoffContext: patch.handoffContext }
278
+ : nodeRun.handoffContext
279
+ ? { handoffContext: nodeRun.handoffContext }
280
+ : {}),
268
281
  ...(patch.latestAttemptId === null
269
282
  ? {}
270
283
  : patch.latestAttemptId !== undefined
@@ -404,9 +417,12 @@ class PlanExecutorService {
404
417
  attachmentPolicy: nodeSpec.contextPolicy.attachmentPolicy,
405
418
  webPolicy: nodeSpec.contextPolicy.webPolicy,
406
419
  },
420
+ executionVisibility: nodeSpec.executionVisibility,
421
+ ...(nodeSpec.escalation ? { escalation: nodeSpec.escalation } : {}),
407
422
  },
408
423
  result: params.result,
409
424
  })
425
+ const emittedEvents: PlanEventRecord[] = []
410
426
 
411
427
  await databaseService.withTransaction(async (tx) => {
412
428
  const attempt = await this.createAttempt({
@@ -462,6 +478,7 @@ class PlanExecutorService {
462
478
  latestAttemptId: finalizedAttempt.id,
463
479
  latestStructuredOutput: params.result.structuredOutput ?? null,
464
480
  latestNotes: params.result.notes ?? null,
481
+ handoffContext: params.result.handoffContext ?? null,
465
482
  }),
466
483
  )
467
484
  .output('after'),
@@ -507,6 +524,21 @@ class PlanExecutorService {
507
524
  message: `Validation failed for node "${nodeSpec.label}", scheduling retry.`,
508
525
  detail: { issues: validation.blocking.map((issue) => issue.code) },
509
526
  emittedBy: params.emittedBy,
527
+ capturedEvents: emittedEvents,
528
+ })
529
+ await this.emitEvent({
530
+ tx,
531
+ run,
532
+ spec,
533
+ nodeId: nextNodeRun.nodeId,
534
+ attemptId: finalizedAttempt.id,
535
+ eventType: 'node-unblocked',
536
+ fromStatus: nodeRun.status,
537
+ toStatus: nextNodeRun.status,
538
+ message: `Node "${nodeSpec.label}" is ready for another attempt.`,
539
+ detail: { retryCount: nextNodeRun.retryCount },
540
+ emittedBy: params.emittedBy,
541
+ capturedEvents: emittedEvents,
510
542
  })
511
543
 
512
544
  const synced = await this.syncRunGraph({
@@ -519,6 +551,7 @@ class PlanExecutorService {
519
551
  ),
520
552
  artifacts: nextArtifacts,
521
553
  emittedBy: params.emittedBy,
554
+ capturedEvents: emittedEvents,
522
555
  })
523
556
 
524
557
  const checkpoint = await this.saveCheckpoint({
@@ -528,6 +561,7 @@ class PlanExecutorService {
528
561
  artifacts: synced.artifacts,
529
562
  sequence: (latestCheckpoint?.sequence ?? 0) + 1,
530
563
  reason: 'node-result-retry',
564
+ capturedEvents: emittedEvents,
531
565
  })
532
566
 
533
567
  await this.attachCheckpoint(tx, synced.run, checkpoint)
@@ -587,6 +621,7 @@ class PlanExecutorService {
587
621
  message: `Node "${nodeSpec.label}" requires human review before continuing.`,
588
622
  detail: { issues: validation.blocking.map((issue) => issue.code) },
589
623
  emittedBy: params.emittedBy,
624
+ capturedEvents: emittedEvents,
590
625
  })
591
626
 
592
627
  const checkpoint = await this.saveCheckpoint({
@@ -598,6 +633,7 @@ class PlanExecutorService {
598
633
  artifacts: nextArtifacts,
599
634
  sequence: (latestCheckpoint?.sequence ?? 0) + 1,
600
635
  reason: 'node-result-human-review',
636
+ capturedEvents: emittedEvents,
601
637
  })
602
638
 
603
639
  await this.attachCheckpoint(tx, failedRun, checkpoint)
@@ -639,6 +675,7 @@ class PlanExecutorService {
639
675
  message: `Node "${nodeSpec.label}" failed validation and requires replanning.`,
640
676
  detail: { failureClass: validation.failureClass, issues: validation.blocking.map((issue) => issue.code) },
641
677
  emittedBy: params.emittedBy,
678
+ capturedEvents: emittedEvents,
642
679
  })
643
680
 
644
681
  const checkpoint = await this.saveCheckpoint({
@@ -650,6 +687,7 @@ class PlanExecutorService {
650
687
  artifacts: nextArtifacts,
651
688
  sequence: (latestCheckpoint?.sequence ?? 0) + 1,
652
689
  reason: 'node-result-replan',
690
+ capturedEvents: emittedEvents,
653
691
  })
654
692
 
655
693
  await this.attachCheckpoint(tx, blockedRun, checkpoint)
@@ -692,6 +730,7 @@ class PlanExecutorService {
692
730
  message: `Node "${nodeSpec.label}" failed validation and the run has been aborted.`,
693
731
  detail: { failureClass: validation.failureClass, issues: validation.blocking.map((issue) => issue.code) },
694
732
  emittedBy: params.emittedBy,
733
+ capturedEvents: emittedEvents,
695
734
  })
696
735
 
697
736
  const checkpoint = await this.saveCheckpoint({
@@ -703,6 +742,7 @@ class PlanExecutorService {
703
742
  artifacts: nextArtifacts,
704
743
  sequence: (latestCheckpoint?.sequence ?? 0) + 1,
705
744
  reason: 'node-result-failed',
745
+ capturedEvents: emittedEvents,
706
746
  })
707
747
 
708
748
  await this.attachCheckpoint(tx, failedRun, checkpoint)
@@ -718,6 +758,7 @@ class PlanExecutorService {
718
758
  latestAttemptId: finalizedAttempt.id,
719
759
  latestStructuredOutput: params.result.structuredOutput ?? null,
720
760
  latestNotes: params.result.notes ?? null,
761
+ handoffContext: params.result.handoffContext ?? null,
721
762
  blockedReason: null,
722
763
  failureClass: null,
723
764
  completedAt: new Date(),
@@ -741,6 +782,7 @@ class PlanExecutorService {
741
782
  : `Node "${nodeSpec.label}" completed successfully.`,
742
783
  detail: { warningCount: validation.warnings.length },
743
784
  emittedBy: params.emittedBy,
785
+ capturedEvents: emittedEvents,
744
786
  })
745
787
 
746
788
  const synced = await this.syncRunGraph({
@@ -753,6 +795,7 @@ class PlanExecutorService {
753
795
  ),
754
796
  artifacts: nextArtifacts,
755
797
  emittedBy: params.emittedBy,
798
+ capturedEvents: emittedEvents,
756
799
  })
757
800
 
758
801
  const checkpoint = await this.saveCheckpoint({
@@ -762,6 +805,7 @@ class PlanExecutorService {
762
805
  artifacts: synced.artifacts,
763
806
  sequence: (latestCheckpoint?.sequence ?? 0) + 1,
764
807
  reason: 'node-result-complete',
808
+ capturedEvents: emittedEvents,
765
809
  })
766
810
 
767
811
  await this.attachCheckpoint(tx, synced.run, checkpoint)
@@ -802,7 +846,7 @@ class PlanExecutorService {
802
846
  .then(async (recommendations) => {
803
847
  if (recommendations.length === 0) return
804
848
  const specRecord = await planRunService.getPlanSpecById(updatedRun.planSpecId)
805
- await databaseService.create(
849
+ const event = await databaseService.create(
806
850
  TABLES.PLAN_EVENT,
807
851
  {
808
852
  planSpecId: ensureRecordId(specRecord.id, TABLES.PLAN_SPEC),
@@ -814,6 +858,7 @@ class PlanExecutorService {
814
858
  },
815
859
  PlanEventSchema,
816
860
  )
861
+ await planEventDeliveryService.dispatchEvent(event)
817
862
  })
818
863
  .catch((error) => {
819
864
  aiLogger.warn`Failed to analyze feedback outcomes for run ${runIdStr}: ${error instanceof Error ? error.message : String(error)}`
@@ -823,6 +868,8 @@ class PlanExecutorService {
823
868
  })
824
869
  }
825
870
 
871
+ await planEventDeliveryService.dispatchEvents(emittedEvents)
872
+
826
873
  const snapshot = await planRunService.toSerializablePlan(updatedRun, {
827
874
  includeEvents: true,
828
875
  includeArtifacts: true,
@@ -891,6 +938,8 @@ class PlanExecutorService {
891
938
  attachmentPolicy: nodeSpec.contextPolicy.attachmentPolicy,
892
939
  webPolicy: nodeSpec.contextPolicy.webPolicy,
893
940
  },
941
+ executionVisibility: nodeSpec.executionVisibility,
942
+ ...(nodeSpec.escalation ? { escalation: nodeSpec.escalation } : {}),
894
943
  },
895
944
  result: {
896
945
  structuredOutput: params.response,
@@ -898,6 +947,7 @@ class PlanExecutorService {
898
947
  notes: typeof params.response.comments === 'string' ? params.response.comments : undefined,
899
948
  },
900
949
  })
950
+ const emittedEvents: PlanEventRecord[] = []
901
951
 
902
952
  await databaseService.withTransaction(async (tx) => {
903
953
  const approvalStatus = deriveApprovalStatus(params.response)
@@ -964,6 +1014,7 @@ class PlanExecutorService {
964
1014
  latestAttemptId: attempt.id,
965
1015
  latestStructuredOutput: params.response,
966
1016
  latestNotes: typeof params.response.comments === 'string' ? params.response.comments : null,
1017
+ handoffContext: null,
967
1018
  blockedReason: validation.blocking[0]?.message ?? null,
968
1019
  failureClass: validation.failureClass,
969
1020
  }),
@@ -980,6 +1031,7 @@ class PlanExecutorService {
980
1031
  latestAttemptId: attempt.id,
981
1032
  latestStructuredOutput: params.response,
982
1033
  latestNotes: typeof params.response.comments === 'string' ? params.response.comments : null,
1034
+ handoffContext: null,
983
1035
  blockedReason: null,
984
1036
  failureClass: null,
985
1037
  completedAt: new Date(),
@@ -1014,6 +1066,7 @@ class PlanExecutorService {
1014
1066
  message: `Human response for node "${nodeSpec.label}" blocked execution.`,
1015
1067
  detail: { issues: validation.blocking.map((issue) => issue.code) },
1016
1068
  emittedBy: params.respondedBy,
1069
+ capturedEvents: emittedEvents,
1017
1070
  })
1018
1071
 
1019
1072
  const checkpoint = await this.saveCheckpoint({
@@ -1023,6 +1076,7 @@ class PlanExecutorService {
1023
1076
  artifacts: existingArtifacts,
1024
1077
  sequence: (latestCheckpoint?.sequence ?? 0) + 1,
1025
1078
  reason: 'human-response-blocked',
1079
+ capturedEvents: emittedEvents,
1026
1080
  })
1027
1081
  await this.attachCheckpoint(tx, blockedRun, checkpoint)
1028
1082
  return
@@ -1036,6 +1090,7 @@ class PlanExecutorService {
1036
1090
  nodeRuns,
1037
1091
  artifacts: existingArtifacts,
1038
1092
  emittedBy: params.respondedBy,
1093
+ capturedEvents: emittedEvents,
1039
1094
  })
1040
1095
 
1041
1096
  await this.emitEvent({
@@ -1051,6 +1106,7 @@ class PlanExecutorService {
1051
1106
  message: `Human response for node "${nodeSpec.label}" accepted.`,
1052
1107
  detail: { approvalStatus: approvalStatus },
1053
1108
  emittedBy: params.respondedBy,
1109
+ capturedEvents: emittedEvents,
1054
1110
  })
1055
1111
 
1056
1112
  const checkpoint = await this.saveCheckpoint({
@@ -1060,10 +1116,13 @@ class PlanExecutorService {
1060
1116
  artifacts: synced.artifacts,
1061
1117
  sequence: (latestCheckpoint?.sequence ?? 0) + 1,
1062
1118
  reason: 'human-response-complete',
1119
+ capturedEvents: emittedEvents,
1063
1120
  })
1064
1121
  await this.attachCheckpoint(tx, synced.run, checkpoint)
1065
1122
  })
1066
1123
 
1124
+ await planEventDeliveryService.dispatchEvents(emittedEvents)
1125
+
1067
1126
  return planRunService.toSerializablePlan(await planRunService.getRunById(run.id), {
1068
1127
  includeEvents: true,
1069
1128
  includeArtifacts: true,
@@ -1090,6 +1149,7 @@ class PlanExecutorService {
1090
1149
  const nodeRuns = await planRunService.listNodeRuns(run.id)
1091
1150
  const artifacts = await planRunService.listArtifacts(run.id)
1092
1151
  const latestCheckpoint = await planRunService.getLatestCheckpoint(run.id)
1152
+ const emittedEvents: PlanEventRecord[] = []
1093
1153
 
1094
1154
  await databaseService.withTransaction(async (tx) => {
1095
1155
  let currentNodeRuns = [...nodeRuns]
@@ -1130,6 +1190,7 @@ class PlanExecutorService {
1130
1190
  message: `Run "${spec.title}" resumed from the latest checkpoint.`,
1131
1191
  detail: latestCheckpoint ? { checkpointId: recordIdToString(latestCheckpoint.id, TABLES.PLAN_CHECKPOINT) } : {},
1132
1192
  emittedBy: params.emittedBy,
1193
+ capturedEvents: emittedEvents,
1133
1194
  })
1134
1195
 
1135
1196
  const synced =
@@ -1143,6 +1204,7 @@ class PlanExecutorService {
1143
1204
  nodeRuns: currentNodeRuns,
1144
1205
  artifacts,
1145
1206
  emittedBy: params.emittedBy,
1207
+ capturedEvents: emittedEvents,
1146
1208
  })
1147
1209
 
1148
1210
  const checkpoint = await this.saveCheckpoint({
@@ -1152,10 +1214,13 @@ class PlanExecutorService {
1152
1214
  artifacts: synced.artifacts,
1153
1215
  sequence: (latestCheckpoint?.sequence ?? 0) + 1,
1154
1216
  reason: 'run-resumed',
1217
+ capturedEvents: emittedEvents,
1155
1218
  })
1156
1219
  await this.attachCheckpoint(tx, synced.run, checkpoint)
1157
1220
  })
1158
1221
 
1222
+ await planEventDeliveryService.dispatchEvents(emittedEvents)
1223
+
1159
1224
  const snapshot = await planRunService.toSerializablePlan(await planRunService.getRunById(run.id), {
1160
1225
  includeEvents: true,
1161
1226
  includeArtifacts: true,
@@ -1216,6 +1281,7 @@ class PlanExecutorService {
1216
1281
  const nodeRun = await planRunService.getNodeRunByNodeId(run.id, params.nodeId)
1217
1282
  const artifacts = await planRunService.listArtifacts(run.id)
1218
1283
  const latestCheckpoint = await planRunService.getLatestCheckpoint(run.id)
1284
+ const emittedEvents: PlanEventRecord[] = []
1219
1285
 
1220
1286
  await databaseService.withTransaction(async (tx) => {
1221
1287
  const blockedNodeRun =
@@ -1253,6 +1319,7 @@ class PlanExecutorService {
1253
1319
  message: `Node "${nodeSpec.label}" failed during owner dispatch.`,
1254
1320
  detail: { failureClass: params.failureClass, phase: 'dispatch', error: params.message },
1255
1321
  emittedBy: params.emittedBy,
1322
+ capturedEvents: emittedEvents,
1256
1323
  })
1257
1324
 
1258
1325
  const checkpoint = await this.saveCheckpoint({
@@ -1264,10 +1331,13 @@ class PlanExecutorService {
1264
1331
  artifacts,
1265
1332
  sequence: (latestCheckpoint?.sequence ?? 0) + 1,
1266
1333
  reason: 'owner-dispatch-failed',
1334
+ capturedEvents: emittedEvents,
1267
1335
  })
1268
1336
  await this.attachCheckpoint(tx, blockedRun, checkpoint)
1269
1337
  })
1270
1338
 
1339
+ await planEventDeliveryService.dispatchEvents(emittedEvents)
1340
+
1271
1341
  return planRunService.toSerializablePlan(await planRunService.getRunById(run.id), {
1272
1342
  includeEvents: true,
1273
1343
  includeArtifacts: true,
@@ -1293,6 +1363,7 @@ class PlanExecutorService {
1293
1363
  const nodeRuns = await planRunService.listNodeRuns(run.id)
1294
1364
  const artifacts = await planRunService.listArtifacts(run.id)
1295
1365
  const latestCheckpoint = await planRunService.getLatestCheckpoint(run.id)
1366
+ const emittedEvents: PlanEventRecord[] = []
1296
1367
 
1297
1368
  await databaseService.withTransaction(async (tx) => {
1298
1369
  const readyNodeRun = PlanNodeRunSchema.parse(
@@ -1317,6 +1388,7 @@ class PlanExecutorService {
1317
1388
  toStatus: readyNodeRun.status,
1318
1389
  message: `Node "${nodeSpec?.label ?? params.nodeId}" promoted to ready after delay.`,
1319
1390
  emittedBy: params.emittedBy,
1391
+ capturedEvents: emittedEvents,
1320
1392
  })
1321
1393
 
1322
1394
  const synced = await this.syncRunGraph({
@@ -1327,6 +1399,7 @@ class PlanExecutorService {
1327
1399
  nodeRuns: updatedNodeRuns,
1328
1400
  artifacts,
1329
1401
  emittedBy: params.emittedBy,
1402
+ capturedEvents: emittedEvents,
1330
1403
  })
1331
1404
 
1332
1405
  const checkpoint = await this.saveCheckpoint({
@@ -1336,9 +1409,12 @@ class PlanExecutorService {
1336
1409
  artifacts: synced.artifacts,
1337
1410
  sequence: (latestCheckpoint?.sequence ?? 0) + 1,
1338
1411
  reason: 'delayed-node-promoted',
1412
+ capturedEvents: emittedEvents,
1339
1413
  })
1340
1414
  await this.attachCheckpoint(tx, synced.run, checkpoint)
1341
1415
  })
1416
+
1417
+ await planEventDeliveryService.dispatchEvents(emittedEvents)
1342
1418
  }
1343
1419
 
1344
1420
  async syncRunGraph(params: {
@@ -1357,6 +1433,7 @@ class PlanExecutorService {
1357
1433
  payload?: unknown
1358
1434
  }>
1359
1435
  emittedBy: string
1436
+ capturedEvents?: PlanEventRecord[]
1360
1437
  }): Promise<{
1361
1438
  run: PlanRunRecord
1362
1439
  nodeRuns: PlanNodeRunRecord[]
@@ -1392,6 +1469,7 @@ class PlanExecutorService {
1392
1469
  toStatus: currentRun.status,
1393
1470
  message: `Run blocked: unresolved cross-plan dependencies (${unresolved.map((d) => d.sourcePlanTitle).join(', ')}).`,
1394
1471
  emittedBy: params.emittedBy,
1472
+ capturedEvents: params.capturedEvents,
1395
1473
  })
1396
1474
  return { run: currentRun, nodeRuns: currentNodeRuns, artifacts: currentArtifacts }
1397
1475
  }
@@ -1465,6 +1543,7 @@ class PlanExecutorService {
1465
1543
  toStatus: skippedNodeRun.status,
1466
1544
  message: `Node "${nodeSpec.label}" was skipped because no inbound branch was activated.`,
1467
1545
  emittedBy: params.emittedBy,
1546
+ capturedEvents: params.capturedEvents,
1468
1547
  })
1469
1548
  changed = true
1470
1549
  continue
@@ -1501,6 +1580,7 @@ class PlanExecutorService {
1501
1580
  toStatus: scheduledNodeRun.status,
1502
1581
  message: `Node "${nodeSpec.label}" is scheduled (${nodeSchedule.type}).`,
1503
1582
  emittedBy: params.emittedBy,
1583
+ capturedEvents: params.capturedEvents,
1504
1584
  })
1505
1585
  changed = true
1506
1586
  } else if (nodeSpec.delayAfterPredecessorMs) {
@@ -1531,6 +1611,7 @@ class PlanExecutorService {
1531
1611
  toStatus: scheduledNodeRun.status,
1532
1612
  message: `Node "${nodeSpec.label}" is delayed by ${nodeSpec.delayAfterPredecessorMs}ms after predecessor.`,
1533
1613
  emittedBy: params.emittedBy,
1614
+ capturedEvents: params.capturedEvents,
1534
1615
  })
1535
1616
  changed = true
1536
1617
  } else {
@@ -1551,6 +1632,7 @@ class PlanExecutorService {
1551
1632
  toStatus: readyNodeRun.status,
1552
1633
  message: `Node "${nodeSpec.label}" is ready to execute.`,
1553
1634
  emittedBy: params.emittedBy,
1635
+ capturedEvents: params.capturedEvents,
1554
1636
  })
1555
1637
  changed = true
1556
1638
  }
@@ -1588,6 +1670,7 @@ class PlanExecutorService {
1588
1670
  toStatus: completedNodeRun.status,
1589
1671
  message: `Structural node "${nodeSpec.label}" auto-completed.`,
1590
1672
  emittedBy: params.emittedBy,
1673
+ capturedEvents: params.capturedEvents,
1591
1674
  })
1592
1675
  changed = true
1593
1676
  }
@@ -1657,6 +1740,7 @@ class PlanExecutorService {
1657
1740
  toStatus: currentRun.status,
1658
1741
  message: `Node "${nextHumanNodeSpec.label}" is awaiting human input.`,
1659
1742
  emittedBy: params.emittedBy,
1743
+ capturedEvents: params.capturedEvents,
1660
1744
  })
1661
1745
  } else {
1662
1746
  const nextActionNodeSpec = sortedNodeSpecs.find((nodeSpec) => {
@@ -1696,6 +1780,20 @@ class PlanExecutorService {
1696
1780
  toStatus: runningNodeRun.status,
1697
1781
  message: `Node "${nextActionNodeSpec.label}" is now running.`,
1698
1782
  emittedBy: params.emittedBy,
1783
+ capturedEvents: params.capturedEvents,
1784
+ })
1785
+ await this.emitEvent({
1786
+ tx: params.tx,
1787
+ run: currentRun,
1788
+ spec: params.spec,
1789
+ nodeId: runningNodeRun.nodeId,
1790
+ eventType: 'ownership-transition',
1791
+ fromStatus: params.run.currentNodeId ?? undefined,
1792
+ toStatus: runningNodeRun.nodeId,
1793
+ message: `Execution ownership transitioned to "${nextActionNodeSpec.label}".`,
1794
+ detail: { owner: nextActionNodeSpec.owner },
1795
+ emittedBy: params.emittedBy,
1796
+ capturedEvents: params.capturedEvents,
1699
1797
  })
1700
1798
  } else if (currentNodeRuns.every((nodeRun) => isSuccessfulTerminalStatus(nodeRun.status))) {
1701
1799
  currentRun = await this.replaceRun(params.tx, currentRun, {
@@ -1715,6 +1813,7 @@ class PlanExecutorService {
1715
1813
  toStatus: currentRun.status,
1716
1814
  message: `Run "${params.spec.title}" completed.`,
1717
1815
  emittedBy: params.emittedBy,
1816
+ capturedEvents: params.capturedEvents,
1718
1817
  })
1719
1818
  } else if (hasScheduledOrMonitoring) {
1720
1819
  // Nodes are waiting on schedules/monitors — run stays active
@@ -1863,6 +1962,7 @@ class PlanExecutorService {
1863
1962
  fromStatus?: string
1864
1963
  toStatus?: string
1865
1964
  detail?: Record<string, unknown>
1965
+ capturedEvents?: PlanEventRecord[]
1866
1966
  }): Promise<PlanEventRecord> {
1867
1967
  const eventId = new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7())
1868
1968
  const created = await params.tx
@@ -1882,7 +1982,9 @@ class PlanExecutorService {
1882
1982
  })
1883
1983
  .output('after')
1884
1984
 
1885
- return PlanEventSchema.parse(created)
1985
+ const event = PlanEventSchema.parse(created)
1986
+ params.capturedEvents?.push(event)
1987
+ return event
1886
1988
  }
1887
1989
 
1888
1990
  private async replaceRun(tx: DatabaseTransaction, run: PlanRunRecord, patch: PlanRunUpdate): Promise<PlanRunRecord> {
@@ -1899,6 +2001,7 @@ class PlanExecutorService {
1899
2001
  sequence: number
1900
2002
  reason: string
1901
2003
  includeWorkspace?: boolean
2004
+ capturedEvents?: PlanEventRecord[]
1902
2005
  }) {
1903
2006
  const checkpoint = await planCheckpointService.createCheckpoint({
1904
2007
  tx: params.tx,
@@ -1930,6 +2033,7 @@ class PlanExecutorService {
1930
2033
  message: `Saved checkpoint ${checkpoint.sequence}.`,
1931
2034
  detail: { checkpointId: recordIdToString(checkpoint.id, TABLES.PLAN_CHECKPOINT), reason: params.reason },
1932
2035
  emittedBy: 'system',
2036
+ capturedEvents: params.capturedEvents,
1933
2037
  })
1934
2038
 
1935
2039
  return checkpoint
@@ -13,3 +13,16 @@ export function readPathValue(source: unknown, path: string): unknown {
13
13
  }
14
14
  return current
15
15
  }
16
+
17
+ export function isExecutableConditionExpression(expression: string | undefined): boolean {
18
+ if (!expression?.trim()) return false
19
+
20
+ const normalized = expression.trim()
21
+ if (normalized === 'always') return true
22
+
23
+ if (/^[a-zA-Z0-9_.]+$/.test(normalized)) {
24
+ return true
25
+ }
26
+
27
+ return /^[a-zA-Z0-9_.]+\s*(==|!=|>=|<=|>|<)\s*.+$/.test(normalized)
28
+ }