@lota-sdk/core 0.1.14 → 0.1.16

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 (174) hide show
  1. package/infrastructure/schema/00_identity.surql +0 -2
  2. package/infrastructure/schema/01_memory.surql +1 -1
  3. package/infrastructure/schema/02_execution_plan.surql +62 -1
  4. package/infrastructure/schema/03_learned_skill.surql +1 -1
  5. package/infrastructure/schema/06_playbook.surql +25 -0
  6. package/infrastructure/schema/07_institutional_memory.surql +13 -0
  7. package/infrastructure/schema/08_quality_metrics.surql +17 -0
  8. package/package.json +9 -8
  9. package/src/ai/definitions.ts +80 -2
  10. package/src/ai/embedding-cache.ts +7 -6
  11. package/src/ai/index.ts +0 -1
  12. package/src/bifrost/bifrost.ts +14 -14
  13. package/src/config/agent-defaults.ts +32 -22
  14. package/src/config/agent-types.ts +11 -0
  15. package/src/config/constants.ts +2 -14
  16. package/src/config/debug-logger.ts +5 -1
  17. package/src/config/index.ts +3 -0
  18. package/src/config/logger.ts +7 -9
  19. package/src/config/model-constants.ts +16 -34
  20. package/src/config/search.ts +1 -15
  21. package/src/create-runtime.ts +453 -0
  22. package/src/db/cursor-pagination.ts +3 -6
  23. package/src/db/index.ts +2 -0
  24. package/src/db/memory-store.rows.ts +7 -7
  25. package/src/db/memory-store.ts +24 -24
  26. package/src/db/memory.ts +18 -16
  27. package/src/db/schema-fingerprint.ts +1 -0
  28. package/src/db/service.ts +193 -122
  29. package/src/db/startup.ts +9 -13
  30. package/src/db/surreal-mutation.ts +43 -0
  31. package/src/db/tables.ts +7 -0
  32. package/src/db/workstream-message-row.ts +15 -0
  33. package/src/embeddings/provider.ts +1 -1
  34. package/src/index.ts +1 -1
  35. package/src/queues/context-compaction.queue.ts +17 -52
  36. package/src/queues/delayed-node-promotion.queue.ts +41 -0
  37. package/src/queues/document-processor.queue.ts +7 -7
  38. package/src/queues/index.ts +3 -0
  39. package/src/queues/memory-consolidation.queue.ts +18 -54
  40. package/src/queues/plan-scheduler.queue.ts +97 -0
  41. package/src/queues/post-chat-memory.queue.ts +15 -60
  42. package/src/queues/queue-factory.ts +100 -0
  43. package/src/queues/recent-activity-title-refinement.queue.ts +15 -54
  44. package/src/queues/regular-chat-memory-digest.queue.ts +16 -55
  45. package/src/queues/skill-extraction.queue.ts +15 -50
  46. package/src/queues/workstream-title-generation.queue.ts +15 -51
  47. package/src/redis/connection.ts +12 -3
  48. package/src/redis/index.ts +2 -1
  49. package/src/redis/org-memory-lock.ts +1 -1
  50. package/src/redis/redis-lease-lock.ts +41 -8
  51. package/src/redis/stream-context.ts +11 -0
  52. package/src/runtime/agent-runtime-policy.ts +106 -21
  53. package/src/runtime/agent-stream-helpers.ts +2 -1
  54. package/src/runtime/approval-continuation.ts +12 -6
  55. package/src/runtime/context-compaction-constants.ts +1 -1
  56. package/src/runtime/context-compaction-runtime.ts +7 -5
  57. package/src/runtime/context-compaction.ts +40 -97
  58. package/src/runtime/execution-plan.ts +23 -19
  59. package/src/runtime/graph-designer.ts +15 -0
  60. package/src/runtime/helper-model.ts +10 -196
  61. package/src/runtime/index.ts +14 -1
  62. package/src/runtime/llm-content.ts +1 -1
  63. package/src/runtime/memory-block.ts +11 -12
  64. package/src/runtime/memory-pipeline.ts +26 -10
  65. package/src/runtime/plugin-resolution.ts +35 -0
  66. package/src/runtime/plugin-types.ts +73 -1
  67. package/src/runtime/retrieval-adapters.ts +1 -1
  68. package/src/runtime/runtime-config.ts +25 -12
  69. package/src/runtime/runtime-extensions.ts +91 -15
  70. package/src/runtime/runtime-worker-registry.ts +6 -0
  71. package/src/runtime/team-consultation-orchestrator.ts +45 -28
  72. package/src/runtime/team-consultation-prompts.ts +11 -2
  73. package/src/runtime/title-helpers.ts +11 -4
  74. package/src/runtime/workstream-chat-helpers.ts +6 -7
  75. package/src/runtime/workstream-routing-policy.ts +0 -30
  76. package/src/runtime/workstream-state.ts +17 -7
  77. package/src/services/adaptive-playbook.service.ts +152 -0
  78. package/src/services/agent-executor.service.ts +293 -0
  79. package/src/services/artifact-provenance.service.ts +172 -0
  80. package/src/services/attachment.service.ts +7 -12
  81. package/src/services/context-compaction.service.ts +75 -58
  82. package/src/services/context-enrichment.service.ts +33 -0
  83. package/src/services/coordination-registry.service.ts +117 -0
  84. package/src/services/document-chunk.service.ts +38 -33
  85. package/src/services/domain-agent-executor.service.ts +71 -0
  86. package/src/services/execution-plan.service.ts +271 -50
  87. package/src/services/feedback-loop.service.ts +96 -0
  88. package/src/services/global-orchestrator.service.ts +148 -0
  89. package/src/services/index.ts +26 -0
  90. package/src/services/institutional-memory.service.ts +145 -0
  91. package/src/services/learned-skill.service.ts +30 -15
  92. package/src/services/memory-assessment.service.ts +3 -2
  93. package/src/services/{memory.utils.ts → memory-utils.ts} +4 -13
  94. package/src/services/memory.service.ts +55 -69
  95. package/src/services/monitoring-window.service.ts +86 -0
  96. package/src/services/mutating-approval.service.ts +1 -1
  97. package/src/services/node-workspace.service.ts +155 -0
  98. package/src/services/notification.service.ts +39 -0
  99. package/src/services/organization-member.service.ts +12 -5
  100. package/src/services/organization.service.ts +5 -5
  101. package/src/services/ownership-dispatcher.service.ts +403 -0
  102. package/src/services/plan-approval.service.ts +1 -1
  103. package/src/services/plan-artifact.service.ts +1 -0
  104. package/src/services/plan-builder.service.ts +1 -0
  105. package/src/services/plan-checkpoint.service.ts +30 -2
  106. package/src/services/plan-compiler.service.ts +5 -0
  107. package/src/services/plan-coordination.service.ts +152 -0
  108. package/src/services/plan-cycle.service.ts +284 -0
  109. package/src/services/plan-deadline.service.ts +287 -0
  110. package/src/services/plan-executor.service.ts +386 -58
  111. package/src/services/plan-helpers.ts +15 -0
  112. package/src/services/plan-run.service.ts +41 -7
  113. package/src/services/plan-scheduler.service.ts +240 -0
  114. package/src/services/plan-template.service.ts +117 -0
  115. package/src/services/plan-validator.service.ts +87 -20
  116. package/src/services/plan-workspace.service.ts +83 -0
  117. package/src/services/playbook-registry.service.ts +67 -0
  118. package/src/services/plugin-executor.service.ts +103 -0
  119. package/src/services/quality-metrics.service.ts +132 -0
  120. package/src/services/recent-activity-title.service.ts +3 -10
  121. package/src/services/recent-activity.service.ts +33 -43
  122. package/src/services/skill-resolver.service.ts +19 -0
  123. package/src/services/system-executor.service.ts +105 -0
  124. package/src/services/workstream-message.service.ts +29 -41
  125. package/src/services/workstream-plan-registry.service.ts +22 -0
  126. package/src/services/workstream-title.service.ts +3 -9
  127. package/src/services/{workstream-turn-preparation.ts → workstream-turn-preparation.service.ts} +428 -373
  128. package/src/services/workstream-turn.ts +2 -2
  129. package/src/services/workstream.service.ts +55 -65
  130. package/src/services/workstream.types.ts +10 -19
  131. package/src/services/write-intent-validator.service.ts +81 -0
  132. package/src/storage/attachment-parser.ts +1 -1
  133. package/src/storage/attachment-storage.service.ts +4 -4
  134. package/src/storage/{attachments.utils.ts → attachment-utils.ts} +2 -5
  135. package/src/storage/generated-document-storage.service.ts +3 -2
  136. package/src/storage/index.ts +2 -2
  137. package/src/system-agents/{context-compacter.agent.ts → context-compaction.agent.ts} +4 -4
  138. package/src/system-agents/delegated-agent-factory.ts +5 -2
  139. package/src/system-agents/index.ts +8 -0
  140. package/src/system-agents/memory-reranker.agent.ts +1 -1
  141. package/src/system-agents/memory.agent.ts +1 -1
  142. package/src/system-agents/recent-activity-title-refiner.agent.ts +1 -1
  143. package/src/tools/execution-plan.tool.ts +17 -19
  144. package/src/tools/fetch-webpage.tool.ts +20 -18
  145. package/src/tools/index.ts +2 -3
  146. package/src/tools/read-file-parts.tool.ts +1 -1
  147. package/src/tools/search-web.tool.ts +18 -15
  148. package/src/tools/{search-tools.ts → search.tool.ts} +1 -1
  149. package/src/tools/team-think.tool.ts +14 -8
  150. package/src/tools/{tool-contract.ts → tool-contracts.ts} +9 -2
  151. package/src/utils/async.ts +3 -2
  152. package/src/utils/date-time.ts +4 -32
  153. package/src/utils/env.ts +8 -0
  154. package/src/utils/errors.ts +47 -0
  155. package/src/utils/hono-error-handler.ts +1 -2
  156. package/src/utils/index.ts +19 -2
  157. package/src/utils/string.ts +128 -1
  158. package/src/workers/bootstrap.ts +2 -2
  159. package/src/workers/index.ts +1 -0
  160. package/src/workers/memory-consolidation.worker.ts +12 -12
  161. package/src/workers/regular-chat-memory-digest.helpers.ts +2 -7
  162. package/src/workers/regular-chat-memory-digest.runner.ts +11 -105
  163. package/src/workers/skill-extraction.runner.ts +8 -102
  164. package/src/workers/utils/file-section-chunker.ts +6 -3
  165. package/src/workers/utils/repomix-file-sections.ts +2 -2
  166. package/src/workers/utils/sandbox-error.ts +11 -2
  167. package/src/workers/utils/workstream-message-query.ts +97 -0
  168. package/src/workers/worker-utils.ts +6 -2
  169. package/src/runtime/retrieval-pipeline.ts +0 -3
  170. package/src/runtime.ts +0 -387
  171. package/src/tools/log-hello-world.tool.ts +0 -17
  172. package/src/utils/error.ts +0 -10
  173. /package/src/services/{context-compaction-runtime.ts → context-compaction-runtime.singleton.ts} +0 -0
  174. /package/src/storage/{attachments.types.ts → attachment-types.ts} +0 -0
@@ -55,6 +55,8 @@ function buildProgress(nodeRuns: PlanNodeRunRecord[]) {
55
55
  blocked: 0,
56
56
  failed: 0,
57
57
  skipped: 0,
58
+ scheduled: 0,
59
+ monitoring: 0,
58
60
  } satisfies Record<PlanNodeRunStatus, number>,
59
61
  )
60
62
 
@@ -72,6 +74,8 @@ function buildProgress(nodeRuns: PlanNodeRunRecord[]) {
72
74
  blocked: counts.blocked,
73
75
  failed: counts.failed,
74
76
  skipped: counts.skipped,
77
+ scheduled: counts.scheduled,
78
+ monitoring: counts.monitoring,
75
79
  completionRatio: total > 0 ? Number((completedWork / total).toFixed(4)) : undefined,
76
80
  }
77
81
  }
@@ -164,8 +168,26 @@ class PlanRunService {
164
168
  return spec
165
169
  }
166
170
 
171
+ async listPlanSpecsByWorkstream(workstreamId: RecordIdInput): Promise<PlanSpecRecord[]> {
172
+ return databaseService.findMany(
173
+ TABLES.PLAN_SPEC,
174
+ { workstreamId: ensureRecordId(workstreamId, TABLES.WORKSTREAM) },
175
+ PlanSpecSchema,
176
+ { orderBy: 'createdAt', orderDir: 'DESC' },
177
+ )
178
+ }
179
+
180
+ async listRunsBySpec(planSpecId: RecordIdInput): Promise<PlanRunRecord[]> {
181
+ return databaseService.findMany(
182
+ TABLES.PLAN_RUN,
183
+ { planSpecId: ensureRecordId(planSpecId, TABLES.PLAN_SPEC) },
184
+ PlanRunSchema,
185
+ { orderBy: 'createdAt', orderDir: 'DESC' },
186
+ )
187
+ }
188
+
167
189
  async listNodeSpecs(planSpecId: RecordIdInput): Promise<PlanNodeSpecRecord[]> {
168
- return await databaseService.findMany(
190
+ return databaseService.findMany(
169
191
  TABLES.PLAN_NODE_SPEC,
170
192
  { planSpecId: ensureRecordId(planSpecId, TABLES.PLAN_SPEC) },
171
193
  PlanNodeSpecRecordSchema,
@@ -198,6 +220,11 @@ class PlanRunService {
198
220
  }
199
221
 
200
222
  async getActiveRunRecord(workstreamId: RecordIdInput): Promise<PlanRunRecord | null> {
223
+ const runs = await this.getActiveRunRecords(workstreamId)
224
+ return runs[0] ?? null
225
+ }
226
+
227
+ async getActiveRunRecords(workstreamId: RecordIdInput): Promise<PlanRunRecord[]> {
201
228
  const runs = await databaseService.findMany(
202
229
  TABLES.PLAN_RUN,
203
230
  { workstreamId: ensureRecordId(workstreamId, TABLES.WORKSTREAM) },
@@ -205,11 +232,11 @@ class PlanRunService {
205
232
  { orderBy: 'updatedAt', orderDir: 'DESC' },
206
233
  )
207
234
 
208
- return runs.find((run) => ACTIVE_RUN_STATUSES.has(run.status)) ?? null
235
+ return runs.filter((run) => ACTIVE_RUN_STATUSES.has(run.status))
209
236
  }
210
237
 
211
238
  async listNodeRuns(runId: RecordIdInput): Promise<PlanNodeRunRecord[]> {
212
- return await databaseService.findMany(
239
+ return databaseService.findMany(
213
240
  TABLES.PLAN_NODE_RUN,
214
241
  { runId: ensureRecordId(runId, TABLES.PLAN_RUN) },
215
242
  PlanNodeRunSchema,
@@ -230,7 +257,7 @@ class PlanRunService {
230
257
  }
231
258
 
232
259
  async listArtifacts(runId: RecordIdInput): Promise<PlanArtifactRecord[]> {
233
- return await databaseService.findMany(
260
+ return databaseService.findMany(
234
261
  TABLES.PLAN_ARTIFACT,
235
262
  { runId: ensureRecordId(runId, TABLES.PLAN_RUN) },
236
263
  PlanArtifactSchema,
@@ -239,7 +266,7 @@ class PlanRunService {
239
266
  }
240
267
 
241
268
  async listAttempts(runId: RecordIdInput): Promise<PlanNodeAttemptRecord[]> {
242
- return await databaseService.findMany(
269
+ return databaseService.findMany(
243
270
  TABLES.PLAN_NODE_ATTEMPT,
244
271
  { runId: ensureRecordId(runId, TABLES.PLAN_RUN) },
245
272
  PlanNodeAttemptSchema,
@@ -257,14 +284,14 @@ class PlanRunService {
257
284
  if (params.planSpecId) filter.planSpecId = ensureRecordId(params.planSpecId, TABLES.PLAN_SPEC)
258
285
  if (params.attemptId) filter.attemptId = ensureRecordId(params.attemptId, TABLES.PLAN_NODE_ATTEMPT)
259
286
 
260
- return await databaseService.findMany(TABLES.PLAN_VALIDATION_ISSUE, filter, PlanValidationIssueSchema, {
287
+ return databaseService.findMany(TABLES.PLAN_VALIDATION_ISSUE, filter, PlanValidationIssueSchema, {
261
288
  orderBy: 'createdAt',
262
289
  orderDir: 'ASC',
263
290
  })
264
291
  }
265
292
 
266
293
  async listApprovals(runId: RecordIdInput): Promise<PlanApprovalRecord[]> {
267
- return await databaseService.findMany(
294
+ return databaseService.findMany(
268
295
  TABLES.PLAN_APPROVAL,
269
296
  { runId: ensureRecordId(runId, TABLES.PLAN_RUN) },
270
297
  PlanApprovalSchema,
@@ -352,6 +379,11 @@ class PlanRunService {
352
379
  attachmentPolicy: nodeSpec.contextPolicy.attachmentPolicy,
353
380
  webPolicy: nodeSpec.contextPolicy.webPolicy,
354
381
  },
382
+ schedule: nodeSpec.schedule,
383
+ deadline: nodeSpec.deadline,
384
+ monitoringConfig: nodeSpec.monitoringConfig,
385
+ delayAfterPredecessorMs: nodeSpec.delayAfterPredecessorMs,
386
+ deliberationConfig: nodeSpec.deliberationConfig,
355
387
  status: nodeRun.status,
356
388
  attemptCount: nodeRun.attemptCount,
357
389
  retryCount: nodeRun.retryCount,
@@ -378,9 +410,11 @@ class PlanRunService {
378
410
  version: spec.version,
379
411
  status: run.status,
380
412
  leadAgentId: run.leadAgentId,
413
+ executionMode: spec.executionMode,
381
414
  schemaRegistry: structuredClone(spec.schemaRegistry),
382
415
  entryNodeIds: [...spec.entryNodeIds],
383
416
  edges: [...spec.edges],
417
+ schedule: spec.schedule,
384
418
  activeNodeIds: run.currentNodeId ? [run.currentNodeId] : [],
385
419
  readyNodeIds: [...run.readyNodeIds],
386
420
  waitingNodeId: run.waitingNodeId,
@@ -0,0 +1,240 @@
1
+ import { PlanCycleRecordSchema, PlanScheduleRecordSchema } from '@lota-sdk/shared'
2
+ import type { PlanScheduleRecord, PlanScheduleSpec } from '@lota-sdk/shared'
3
+ import { CronExpressionParser } from 'cron-parser'
4
+
5
+ import type { RecordIdInput } from '../db/record-id'
6
+ import { ensureRecordId, recordIdToString } from '../db/record-id'
7
+ import { databaseService } from '../db/service'
8
+ import { TABLES } from '../db/tables'
9
+ import { toDatabaseDateTime } from '../utils/date-time'
10
+
11
+ class PlanSchedulerService {
12
+ computeNextFireAt(spec: PlanScheduleSpec, baseTime: Date = new Date()): Date | null {
13
+ switch (spec.type) {
14
+ case 'immediate':
15
+ return baseTime
16
+
17
+ case 'absolute':
18
+ return spec.at ? new Date(spec.at) : null
19
+
20
+ case 'relative':
21
+ return spec.delayMs !== undefined ? new Date(baseTime.getTime() + spec.delayMs) : null
22
+
23
+ case 'cron': {
24
+ if (!spec.cron) return null
25
+ try {
26
+ const expr = CronExpressionParser.parse(spec.cron, { currentDate: baseTime })
27
+ const next = expr.next()
28
+ return next.toDate()
29
+ } catch {
30
+ return null
31
+ }
32
+ }
33
+
34
+ case 'monitoring':
35
+ return spec.intervalMs !== undefined ? new Date(baseTime.getTime() + spec.intervalMs) : null
36
+
37
+ default:
38
+ return null
39
+ }
40
+ }
41
+
42
+ async createSchedule(params: {
43
+ organizationId: RecordIdInput
44
+ workstreamId: RecordIdInput
45
+ planSpecId?: RecordIdInput
46
+ runId?: RecordIdInput
47
+ nodeId?: string
48
+ scheduleSpec: PlanScheduleSpec
49
+ }): Promise<PlanScheduleRecord> {
50
+ const nextFireAt = this.computeNextFireAt(params.scheduleSpec)
51
+ const now = new Date()
52
+
53
+ const record = await databaseService.create(
54
+ TABLES.PLAN_SCHEDULE,
55
+ {
56
+ organizationId: ensureRecordId(params.organizationId, TABLES.ORGANIZATION),
57
+ workstreamId: ensureRecordId(params.workstreamId, TABLES.WORKSTREAM),
58
+ planSpecId: params.planSpecId ? ensureRecordId(params.planSpecId, TABLES.PLAN_SPEC) : undefined,
59
+ runId: params.runId ? ensureRecordId(params.runId, TABLES.PLAN_RUN) : undefined,
60
+ nodeId: params.nodeId,
61
+ scheduleSpec: params.scheduleSpec,
62
+ status: 'active',
63
+ fireCount: 0,
64
+ ...(nextFireAt ? { nextFireAt: toDatabaseDateTime(nextFireAt) } : {}),
65
+ createdAt: now,
66
+ },
67
+ PlanScheduleRecordSchema,
68
+ )
69
+
70
+ // Enqueue a delayed BullMQ job to fire at the scheduled time
71
+ if (nextFireAt) {
72
+ const delay = Math.max(0, nextFireAt.getTime() - Date.now())
73
+ const { enqueueScheduleFire } = await import('../queues/plan-scheduler.queue')
74
+ await enqueueScheduleFire(recordIdToString(record.id, TABLES.PLAN_SCHEDULE), delay)
75
+ }
76
+
77
+ return record
78
+ }
79
+
80
+ /** Called by the BullMQ worker when a fire-schedule job executes. */
81
+ async fireScheduleById(scheduleId: string): Promise<void> {
82
+ const schedule = await databaseService.findOne(
83
+ TABLES.PLAN_SCHEDULE,
84
+ { id: ensureRecordId(scheduleId, TABLES.PLAN_SCHEDULE) },
85
+ PlanScheduleRecordSchema,
86
+ )
87
+
88
+ if (!schedule || schedule.status !== 'active') return
89
+
90
+ await this.fireSchedule(schedule)
91
+ }
92
+
93
+ async fireSchedule(schedule: PlanScheduleRecord): Promise<void> {
94
+ const now = new Date()
95
+ const newFireCount = schedule.fireCount + 1
96
+ const isRecurring = schedule.scheduleSpec.type === 'cron' || schedule.scheduleSpec.type === 'monitoring'
97
+ const maxReached = schedule.scheduleSpec.maxFires !== undefined && newFireCount >= schedule.scheduleSpec.maxFires
98
+
99
+ let nextFireAt: Date | null = null
100
+ let newStatus: 'active' | 'completed' = 'completed'
101
+
102
+ if (isRecurring && !maxReached) {
103
+ nextFireAt = this.computeNextFireAt(schedule.scheduleSpec, now)
104
+ newStatus = nextFireAt ? 'active' : 'completed'
105
+ }
106
+
107
+ await databaseService.update(
108
+ TABLES.PLAN_SCHEDULE,
109
+ schedule.id,
110
+ { fireCount: newFireCount, lastFiredAt: now, nextFireAt: toDatabaseDateTime(nextFireAt), status: newStatus },
111
+ PlanScheduleRecordSchema,
112
+ )
113
+
114
+ // Enqueue next fire for recurring schedules
115
+ if (newStatus === 'active' && nextFireAt) {
116
+ const delay = Math.max(0, nextFireAt.getTime() - Date.now())
117
+ const scheduleIdStr = recordIdToString(schedule.id, TABLES.PLAN_SCHEDULE)
118
+ const { enqueueScheduleFire } = await import('../queues/plan-scheduler.queue')
119
+ await enqueueScheduleFire(scheduleIdStr, delay)
120
+ }
121
+
122
+ let shouldRecoverDeadlineChecks = false
123
+
124
+ // Dispatch: node-level schedule — promote the delayed node
125
+ if (schedule.runId && schedule.nodeId) {
126
+ const { planExecutorService } = await import('./plan-executor.service')
127
+ await planExecutorService.promoteDelayedNode({
128
+ runId: recordIdToString(schedule.runId, TABLES.PLAN_RUN),
129
+ nodeId: schedule.nodeId,
130
+ emittedBy: 'plan-scheduler',
131
+ })
132
+ shouldRecoverDeadlineChecks = true
133
+ }
134
+
135
+ // Dispatch: plan-level schedule — advance a linked cycle or create a new run
136
+ if (schedule.planSpecId && !schedule.runId) {
137
+ const cycle = await databaseService.findOne(
138
+ TABLES.PLAN_CYCLE,
139
+ { scheduleId: ensureRecordId(schedule.id, TABLES.PLAN_SCHEDULE) },
140
+ PlanCycleRecordSchema,
141
+ )
142
+
143
+ if (cycle) {
144
+ const { planCycleService } = await import('./plan-cycle.service')
145
+ await planCycleService.advanceCycle(cycle.id)
146
+ shouldRecoverDeadlineChecks = true
147
+ }
148
+ }
149
+
150
+ if (shouldRecoverDeadlineChecks) {
151
+ const { planDeadlineService } = await import('./plan-deadline.service')
152
+ await planDeadlineService.recoverDeadlineChecks()
153
+ }
154
+ }
155
+
156
+ /** Re-enqueue BullMQ jobs for all active schedules. Called once at worker startup. */
157
+ async recoverActiveSchedules(): Promise<void> {
158
+ const activeSchedules = await databaseService.queryMany(
159
+ {
160
+ query: `SELECT * FROM ${TABLES.PLAN_SCHEDULE} WHERE status = $status ORDER BY nextFireAt ASC`,
161
+ bindings: { status: 'active' },
162
+ },
163
+ PlanScheduleRecordSchema,
164
+ )
165
+
166
+ const { enqueueScheduleFire } = await import('../queues/plan-scheduler.queue')
167
+
168
+ for (const schedule of activeSchedules) {
169
+ if (!schedule.nextFireAt) continue
170
+ const scheduleId = recordIdToString(schedule.id, TABLES.PLAN_SCHEDULE)
171
+ const delay = Math.max(0, new Date(schedule.nextFireAt).getTime() - Date.now())
172
+ await enqueueScheduleFire(scheduleId, delay)
173
+ }
174
+ }
175
+
176
+ async cancelSchedule(scheduleId: RecordIdInput): Promise<void> {
177
+ const idStr = recordIdToString(scheduleId, TABLES.PLAN_SCHEDULE)
178
+ const { removeScheduleFireJob } = await import('../queues/plan-scheduler.queue')
179
+ await removeScheduleFireJob(idStr)
180
+
181
+ await databaseService.update(
182
+ TABLES.PLAN_SCHEDULE,
183
+ ensureRecordId(scheduleId, TABLES.PLAN_SCHEDULE),
184
+ { status: 'cancelled' },
185
+ PlanScheduleRecordSchema,
186
+ )
187
+ }
188
+
189
+ async pauseSchedule(scheduleId: RecordIdInput): Promise<void> {
190
+ const idStr = recordIdToString(scheduleId, TABLES.PLAN_SCHEDULE)
191
+ const { removeScheduleFireJob } = await import('../queues/plan-scheduler.queue')
192
+ await removeScheduleFireJob(idStr)
193
+
194
+ await databaseService.update(
195
+ TABLES.PLAN_SCHEDULE,
196
+ ensureRecordId(scheduleId, TABLES.PLAN_SCHEDULE),
197
+ { status: 'paused' },
198
+ PlanScheduleRecordSchema,
199
+ )
200
+ }
201
+
202
+ async resumeSchedule(scheduleId: RecordIdInput): Promise<void> {
203
+ const schedule = await databaseService.findOne(
204
+ TABLES.PLAN_SCHEDULE,
205
+ { id: ensureRecordId(scheduleId, TABLES.PLAN_SCHEDULE) },
206
+ PlanScheduleRecordSchema,
207
+ )
208
+ if (!schedule) {
209
+ throw new Error(`Schedule not found: ${recordIdToString(scheduleId, TABLES.PLAN_SCHEDULE)}`)
210
+ }
211
+
212
+ const nextFireAt = this.computeNextFireAt(schedule.scheduleSpec)
213
+
214
+ await databaseService.update(
215
+ TABLES.PLAN_SCHEDULE,
216
+ ensureRecordId(scheduleId, TABLES.PLAN_SCHEDULE),
217
+ { status: 'active', nextFireAt: toDatabaseDateTime(nextFireAt) },
218
+ PlanScheduleRecordSchema,
219
+ )
220
+
221
+ // Re-enqueue the delayed fire job
222
+ if (nextFireAt) {
223
+ const delay = Math.max(0, nextFireAt.getTime() - Date.now())
224
+ const idStr = recordIdToString(scheduleId, TABLES.PLAN_SCHEDULE)
225
+ const { enqueueScheduleFire } = await import('../queues/plan-scheduler.queue')
226
+ await enqueueScheduleFire(idStr, delay)
227
+ }
228
+ }
229
+
230
+ async listSchedules(workstreamId: RecordIdInput): Promise<PlanScheduleRecord[]> {
231
+ return databaseService.findMany(
232
+ TABLES.PLAN_SCHEDULE,
233
+ { workstreamId: ensureRecordId(workstreamId, TABLES.WORKSTREAM) },
234
+ PlanScheduleRecordSchema,
235
+ { orderBy: 'createdAt', orderDir: 'ASC' },
236
+ )
237
+ }
238
+ }
239
+
240
+ export const planSchedulerService = new PlanSchedulerService()
@@ -0,0 +1,117 @@
1
+ import { PlanTemplateRecordSchema } from '@lota-sdk/shared'
2
+ import type { ExecutionPlanToolResultData, PlanArtifactRecord, PlanDraft, PlanTemplateRecord } from '@lota-sdk/shared'
3
+
4
+ import type { RecordIdInput } from '../db/record-id'
5
+ import { ensureRecordId, recordIdToString } from '../db/record-id'
6
+ import { databaseService } from '../db/service'
7
+ import { TABLES } from '../db/tables'
8
+ import { executionPlanService } from './execution-plan.service'
9
+
10
+ class PlanTemplateService {
11
+ async createTemplate(params: {
12
+ organizationId: RecordIdInput
13
+ name: string
14
+ description?: string
15
+ draft: PlanDraft
16
+ tags?: string[]
17
+ source?: 'user' | 'playbook' | 'system'
18
+ sourceRef?: string
19
+ }): Promise<PlanTemplateRecord> {
20
+ const now = new Date()
21
+ return databaseService.create(
22
+ TABLES.PLAN_TEMPLATE,
23
+ {
24
+ organizationId: ensureRecordId(params.organizationId, TABLES.ORGANIZATION),
25
+ name: params.name,
26
+ ...(params.description ? { description: params.description } : {}),
27
+ draft: params.draft,
28
+ tags: params.tags ?? [],
29
+ source: params.source ?? 'user',
30
+ ...(params.sourceRef ? { sourceRef: params.sourceRef } : {}),
31
+ createdAt: now,
32
+ },
33
+ PlanTemplateRecordSchema,
34
+ )
35
+ }
36
+
37
+ async getTemplate(templateId: RecordIdInput): Promise<PlanTemplateRecord | null> {
38
+ return databaseService.findOne(
39
+ TABLES.PLAN_TEMPLATE,
40
+ { id: ensureRecordId(templateId, TABLES.PLAN_TEMPLATE) },
41
+ PlanTemplateRecordSchema,
42
+ )
43
+ }
44
+
45
+ async listTemplates(
46
+ organizationId: RecordIdInput,
47
+ params?: { tags?: string[]; source?: string },
48
+ ): Promise<PlanTemplateRecord[]> {
49
+ const filter: Record<string, unknown> = { organizationId: ensureRecordId(organizationId, TABLES.ORGANIZATION) }
50
+ if (params?.source) {
51
+ filter.source = params.source
52
+ }
53
+
54
+ const templates = await databaseService.findMany(TABLES.PLAN_TEMPLATE, filter, PlanTemplateRecordSchema, {
55
+ orderBy: 'createdAt',
56
+ orderDir: 'ASC',
57
+ })
58
+
59
+ if (params?.tags && params.tags.length > 0) {
60
+ const tagSet = new Set(params.tags)
61
+ return templates.filter((t) => t.tags.some((tag) => tagSet.has(tag)))
62
+ }
63
+
64
+ return templates
65
+ }
66
+
67
+ async updateTemplate(
68
+ templateId: RecordIdInput,
69
+ patch: Partial<{ name: string; description: string; draft: PlanDraft; tags: string[] }>,
70
+ ): Promise<PlanTemplateRecord> {
71
+ const updated = await databaseService.update(
72
+ TABLES.PLAN_TEMPLATE,
73
+ ensureRecordId(templateId, TABLES.PLAN_TEMPLATE),
74
+ patch,
75
+ PlanTemplateRecordSchema,
76
+ )
77
+ if (!updated) {
78
+ throw new Error(`Template not found: ${recordIdToString(templateId, TABLES.PLAN_TEMPLATE)}`)
79
+ }
80
+ return updated
81
+ }
82
+
83
+ async deleteTemplate(templateId: RecordIdInput): Promise<void> {
84
+ await databaseService.deleteById(TABLES.PLAN_TEMPLATE, ensureRecordId(templateId, TABLES.PLAN_TEMPLATE))
85
+ }
86
+
87
+ async instantiate(params: {
88
+ templateId: RecordIdInput
89
+ organizationId: RecordIdInput
90
+ workstreamId: RecordIdInput
91
+ leadAgentId: string
92
+ overrides?: Partial<PlanDraft>
93
+ carryForwardArtifacts?: PlanArtifactRecord[]
94
+ }): Promise<ExecutionPlanToolResultData> {
95
+ const template = await this.getTemplate(params.templateId)
96
+ if (!template) {
97
+ throw new Error(`Template not found: ${recordIdToString(params.templateId, TABLES.PLAN_TEMPLATE)}`)
98
+ }
99
+
100
+ const draft: PlanDraft = { ...template.draft, ...params.overrides }
101
+
102
+ if (params.carryForwardArtifacts && params.carryForwardArtifacts.length > 0) {
103
+ const carryContext = params.carryForwardArtifacts.map((a) => `[carry-forward] ${a.name}: ${a.pointer}`)
104
+ draft.objective = `${draft.objective}\n\nCarry-forward context:\n${carryContext.join('\n')}`
105
+ }
106
+
107
+ return executionPlanService.createPlan({
108
+ organizationId: params.organizationId,
109
+ workstreamId: params.workstreamId,
110
+ leadAgentId: params.leadAgentId,
111
+ dispatchMode: 'stable-boundary',
112
+ input: draft,
113
+ })
114
+ }
115
+ }
116
+
117
+ export const planTemplateService = new PlanTemplateService()
@@ -8,7 +8,11 @@ import type {
8
8
  PlanValidationIssueSeverity,
9
9
  } from '@lota-sdk/shared'
10
10
 
11
- const STRUCTURAL_NODE_TYPES = new Set(['switch', 'join'])
11
+ import { isRecord } from '../utils/string'
12
+ import { planCoordinationService } from './plan-coordination.service'
13
+ import { readPathValue } from './plan-helpers'
14
+
15
+ const STRUCTURAL_NODE_TYPES = new Set(['switch', 'join', 'deliberation-fork'])
12
16
  const HUMAN_NODE_TYPES = new Set(['human-input', 'human-approval', 'human-review-edit', 'human-decision'])
13
17
 
14
18
  export interface PlanValidationIssueInput {
@@ -46,30 +50,12 @@ function createIssue(params: {
46
50
  }
47
51
  }
48
52
 
49
- function isRecord(value: unknown): value is Record<string, unknown> {
50
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
51
- }
52
-
53
- function readPathValue(source: unknown, path: string): unknown {
54
- if (!path.trim()) return source
55
-
56
- let current: unknown = source
57
- for (const segment of path
58
- .split('.')
59
- .map((part) => part.trim())
60
- .filter(Boolean)) {
61
- if (!isRecord(current)) return undefined
62
- current = current[segment]
63
- }
64
- return current
65
- }
66
-
67
53
  function hasAllFields(value: unknown, fields: string[]): boolean {
68
54
  if (!isRecord(value)) return false
69
55
  return fields.every((field) => readPathValue(value, field) !== undefined)
70
56
  }
71
57
 
72
- function validateSchemaValue(params: { schema: PlanDataSchema; value: unknown; path: string }): string[] {
58
+ export function validateSchemaValue(params: { schema: PlanDataSchema; value: unknown; path: string }): string[] {
73
59
  const issues: string[] = []
74
60
  const { schema, value, path } = params
75
61
 
@@ -251,6 +237,24 @@ class PlanValidatorService {
251
237
  }),
252
238
  )
253
239
  }
240
+ if (node.owner.executorType === 'plugin' && node.owner.operation.trim().length === 0) {
241
+ blocking.push(
242
+ createIssue({
243
+ code: 'plugin_owner_operation_missing',
244
+ message: `Plugin-owned node "${node.label}" must declare an operation.`,
245
+ nodeId: node.id,
246
+ }),
247
+ )
248
+ }
249
+ if (node.owner.executorType === 'system' && node.owner.operation.trim().length === 0) {
250
+ blocking.push(
251
+ createIssue({
252
+ code: 'system_owner_operation_missing',
253
+ message: `System-owned node "${node.label}" must declare an operation.`,
254
+ nodeId: node.id,
255
+ }),
256
+ )
257
+ }
254
258
  if (node.inputSchemaRef && !resolveSchemaRef(draft, node.inputSchemaRef)) {
255
259
  blocking.push(
256
260
  createIssue({
@@ -304,6 +308,61 @@ class PlanValidatorService {
304
308
  }),
305
309
  )
306
310
  }
311
+
312
+ // Validate deliberation-fork nodes
313
+ if (node.type === 'deliberation-fork') {
314
+ if (!node.deliberationConfig) {
315
+ blocking.push(
316
+ createIssue({
317
+ code: 'deliberation_fork_missing_config',
318
+ message: `Deliberation-fork node "${node.label}" must define deliberationConfig.`,
319
+ nodeId: node.id,
320
+ }),
321
+ )
322
+ } else {
323
+ if (node.deliberationConfig.branches.length < 2) {
324
+ blocking.push(
325
+ createIssue({
326
+ code: 'deliberation_fork_insufficient_branches',
327
+ message: `Deliberation-fork node "${node.label}" must define at least 2 branches.`,
328
+ nodeId: node.id,
329
+ }),
330
+ )
331
+ }
332
+ for (const branch of node.deliberationConfig.branches) {
333
+ if (!nodesById.has(branch.entryNodeId)) {
334
+ blocking.push(
335
+ createIssue({
336
+ code: 'deliberation_fork_missing_branch_entry',
337
+ message: `Deliberation-fork node "${node.label}" references missing branch entry node "${branch.entryNodeId}".`,
338
+ nodeId: node.id,
339
+ detail: { branchId: branch.branchId, entryNodeId: branch.entryNodeId },
340
+ }),
341
+ )
342
+ }
343
+ }
344
+ const gateNode = nodesById.get(node.deliberationConfig.resolutionGateNodeId)
345
+ if (!gateNode) {
346
+ blocking.push(
347
+ createIssue({
348
+ code: 'deliberation_fork_missing_gate',
349
+ message: `Deliberation-fork node "${node.label}" references missing resolution gate node "${node.deliberationConfig.resolutionGateNodeId}".`,
350
+ nodeId: node.id,
351
+ detail: { resolutionGateNodeId: node.deliberationConfig.resolutionGateNodeId },
352
+ }),
353
+ )
354
+ } else if (gateNode.type !== 'human-decision') {
355
+ blocking.push(
356
+ createIssue({
357
+ code: 'deliberation_fork_gate_type_mismatch',
358
+ message: `Resolution gate node "${gateNode.label}" must be of type "human-decision".`,
359
+ nodeId: node.id,
360
+ detail: { gateNodeId: gateNode.id, gateNodeType: gateNode.type },
361
+ }),
362
+ )
363
+ }
364
+ }
365
+ }
307
366
  }
308
367
 
309
368
  const entryNodeIds = draft.entryNodeIds ?? []
@@ -446,6 +505,14 @@ class PlanValidatorService {
446
505
  )
447
506
  }
448
507
 
508
+ // Validate cross-plan dependency cycles
509
+ if (draft.dependencies && draft.dependencies.length > 0) {
510
+ const cycleIssues = planCoordinationService.validateNoCycles([
511
+ { title: draft.title, dependencies: draft.dependencies },
512
+ ])
513
+ blocking.push(...cycleIssues)
514
+ }
515
+
449
516
  return { blocking, warnings }
450
517
  }
451
518