@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.
- package/infrastructure/schema/00_identity.surql +0 -2
- package/infrastructure/schema/01_memory.surql +1 -1
- package/infrastructure/schema/02_execution_plan.surql +62 -1
- package/infrastructure/schema/03_learned_skill.surql +1 -1
- package/infrastructure/schema/06_playbook.surql +25 -0
- package/infrastructure/schema/07_institutional_memory.surql +13 -0
- package/infrastructure/schema/08_quality_metrics.surql +17 -0
- package/package.json +9 -8
- package/src/ai/definitions.ts +80 -2
- package/src/ai/embedding-cache.ts +7 -6
- package/src/ai/index.ts +0 -1
- package/src/bifrost/bifrost.ts +14 -14
- package/src/config/agent-defaults.ts +32 -22
- package/src/config/agent-types.ts +11 -0
- package/src/config/constants.ts +2 -14
- package/src/config/debug-logger.ts +5 -1
- package/src/config/index.ts +3 -0
- package/src/config/logger.ts +7 -9
- package/src/config/model-constants.ts +16 -34
- package/src/config/search.ts +1 -15
- package/src/create-runtime.ts +453 -0
- package/src/db/cursor-pagination.ts +3 -6
- package/src/db/index.ts +2 -0
- package/src/db/memory-store.rows.ts +7 -7
- package/src/db/memory-store.ts +24 -24
- package/src/db/memory.ts +18 -16
- package/src/db/schema-fingerprint.ts +1 -0
- package/src/db/service.ts +193 -122
- package/src/db/startup.ts +9 -13
- package/src/db/surreal-mutation.ts +43 -0
- package/src/db/tables.ts +7 -0
- package/src/db/workstream-message-row.ts +15 -0
- package/src/embeddings/provider.ts +1 -1
- package/src/index.ts +1 -1
- package/src/queues/context-compaction.queue.ts +17 -52
- package/src/queues/delayed-node-promotion.queue.ts +41 -0
- package/src/queues/document-processor.queue.ts +7 -7
- package/src/queues/index.ts +3 -0
- package/src/queues/memory-consolidation.queue.ts +18 -54
- package/src/queues/plan-scheduler.queue.ts +97 -0
- package/src/queues/post-chat-memory.queue.ts +15 -60
- package/src/queues/queue-factory.ts +100 -0
- package/src/queues/recent-activity-title-refinement.queue.ts +15 -54
- package/src/queues/regular-chat-memory-digest.queue.ts +16 -55
- package/src/queues/skill-extraction.queue.ts +15 -50
- package/src/queues/workstream-title-generation.queue.ts +15 -51
- package/src/redis/connection.ts +12 -3
- package/src/redis/index.ts +2 -1
- package/src/redis/org-memory-lock.ts +1 -1
- package/src/redis/redis-lease-lock.ts +41 -8
- package/src/redis/stream-context.ts +11 -0
- package/src/runtime/agent-runtime-policy.ts +106 -21
- package/src/runtime/agent-stream-helpers.ts +2 -1
- package/src/runtime/approval-continuation.ts +12 -6
- package/src/runtime/context-compaction-constants.ts +1 -1
- package/src/runtime/context-compaction-runtime.ts +7 -5
- package/src/runtime/context-compaction.ts +40 -97
- package/src/runtime/execution-plan.ts +23 -19
- package/src/runtime/graph-designer.ts +15 -0
- package/src/runtime/helper-model.ts +10 -196
- package/src/runtime/index.ts +14 -1
- package/src/runtime/llm-content.ts +1 -1
- package/src/runtime/memory-block.ts +11 -12
- package/src/runtime/memory-pipeline.ts +26 -10
- package/src/runtime/plugin-resolution.ts +35 -0
- package/src/runtime/plugin-types.ts +73 -1
- package/src/runtime/retrieval-adapters.ts +1 -1
- package/src/runtime/runtime-config.ts +25 -12
- package/src/runtime/runtime-extensions.ts +91 -15
- package/src/runtime/runtime-worker-registry.ts +6 -0
- package/src/runtime/team-consultation-orchestrator.ts +45 -28
- package/src/runtime/team-consultation-prompts.ts +11 -2
- package/src/runtime/title-helpers.ts +11 -4
- package/src/runtime/workstream-chat-helpers.ts +6 -7
- package/src/runtime/workstream-routing-policy.ts +0 -30
- package/src/runtime/workstream-state.ts +17 -7
- package/src/services/adaptive-playbook.service.ts +152 -0
- package/src/services/agent-executor.service.ts +293 -0
- package/src/services/artifact-provenance.service.ts +172 -0
- package/src/services/attachment.service.ts +7 -12
- package/src/services/context-compaction.service.ts +75 -58
- package/src/services/context-enrichment.service.ts +33 -0
- package/src/services/coordination-registry.service.ts +117 -0
- package/src/services/document-chunk.service.ts +38 -33
- package/src/services/domain-agent-executor.service.ts +71 -0
- package/src/services/execution-plan.service.ts +271 -50
- package/src/services/feedback-loop.service.ts +96 -0
- package/src/services/global-orchestrator.service.ts +148 -0
- package/src/services/index.ts +26 -0
- package/src/services/institutional-memory.service.ts +145 -0
- package/src/services/learned-skill.service.ts +30 -15
- package/src/services/memory-assessment.service.ts +3 -2
- package/src/services/{memory.utils.ts → memory-utils.ts} +4 -13
- package/src/services/memory.service.ts +55 -69
- package/src/services/monitoring-window.service.ts +86 -0
- package/src/services/mutating-approval.service.ts +1 -1
- package/src/services/node-workspace.service.ts +155 -0
- package/src/services/notification.service.ts +39 -0
- package/src/services/organization-member.service.ts +12 -5
- package/src/services/organization.service.ts +5 -5
- package/src/services/ownership-dispatcher.service.ts +403 -0
- package/src/services/plan-approval.service.ts +1 -1
- package/src/services/plan-artifact.service.ts +1 -0
- package/src/services/plan-builder.service.ts +1 -0
- package/src/services/plan-checkpoint.service.ts +30 -2
- package/src/services/plan-compiler.service.ts +5 -0
- package/src/services/plan-coordination.service.ts +152 -0
- package/src/services/plan-cycle.service.ts +284 -0
- package/src/services/plan-deadline.service.ts +287 -0
- package/src/services/plan-executor.service.ts +386 -58
- package/src/services/plan-helpers.ts +15 -0
- package/src/services/plan-run.service.ts +41 -7
- package/src/services/plan-scheduler.service.ts +240 -0
- package/src/services/plan-template.service.ts +117 -0
- package/src/services/plan-validator.service.ts +87 -20
- package/src/services/plan-workspace.service.ts +83 -0
- package/src/services/playbook-registry.service.ts +67 -0
- package/src/services/plugin-executor.service.ts +103 -0
- package/src/services/quality-metrics.service.ts +132 -0
- package/src/services/recent-activity-title.service.ts +3 -10
- package/src/services/recent-activity.service.ts +33 -43
- package/src/services/skill-resolver.service.ts +19 -0
- package/src/services/system-executor.service.ts +105 -0
- package/src/services/workstream-message.service.ts +29 -41
- package/src/services/workstream-plan-registry.service.ts +22 -0
- package/src/services/workstream-title.service.ts +3 -9
- package/src/services/{workstream-turn-preparation.ts → workstream-turn-preparation.service.ts} +428 -373
- package/src/services/workstream-turn.ts +2 -2
- package/src/services/workstream.service.ts +55 -65
- package/src/services/workstream.types.ts +10 -19
- package/src/services/write-intent-validator.service.ts +81 -0
- package/src/storage/attachment-parser.ts +1 -1
- package/src/storage/attachment-storage.service.ts +4 -4
- package/src/storage/{attachments.utils.ts → attachment-utils.ts} +2 -5
- package/src/storage/generated-document-storage.service.ts +3 -2
- package/src/storage/index.ts +2 -2
- package/src/system-agents/{context-compacter.agent.ts → context-compaction.agent.ts} +4 -4
- package/src/system-agents/delegated-agent-factory.ts +5 -2
- package/src/system-agents/index.ts +8 -0
- package/src/system-agents/memory-reranker.agent.ts +1 -1
- package/src/system-agents/memory.agent.ts +1 -1
- package/src/system-agents/recent-activity-title-refiner.agent.ts +1 -1
- package/src/tools/execution-plan.tool.ts +17 -19
- package/src/tools/fetch-webpage.tool.ts +20 -18
- package/src/tools/index.ts +2 -3
- package/src/tools/read-file-parts.tool.ts +1 -1
- package/src/tools/search-web.tool.ts +18 -15
- package/src/tools/{search-tools.ts → search.tool.ts} +1 -1
- package/src/tools/team-think.tool.ts +14 -8
- package/src/tools/{tool-contract.ts → tool-contracts.ts} +9 -2
- package/src/utils/async.ts +3 -2
- package/src/utils/date-time.ts +4 -32
- package/src/utils/env.ts +8 -0
- package/src/utils/errors.ts +47 -0
- package/src/utils/hono-error-handler.ts +1 -2
- package/src/utils/index.ts +19 -2
- package/src/utils/string.ts +128 -1
- package/src/workers/bootstrap.ts +2 -2
- package/src/workers/index.ts +1 -0
- package/src/workers/memory-consolidation.worker.ts +12 -12
- package/src/workers/regular-chat-memory-digest.helpers.ts +2 -7
- package/src/workers/regular-chat-memory-digest.runner.ts +11 -105
- package/src/workers/skill-extraction.runner.ts +8 -102
- package/src/workers/utils/file-section-chunker.ts +6 -3
- package/src/workers/utils/repomix-file-sections.ts +2 -2
- package/src/workers/utils/sandbox-error.ts +11 -2
- package/src/workers/utils/workstream-message-query.ts +97 -0
- package/src/workers/worker-utils.ts +6 -2
- package/src/runtime/retrieval-pipeline.ts +0 -3
- package/src/runtime.ts +0 -387
- package/src/tools/log-hello-world.tool.ts +0 -17
- package/src/utils/error.ts +0 -10
- /package/src/services/{context-compaction-runtime.ts → context-compaction-runtime.singleton.ts} +0 -0
- /package/src/storage/{attachments.types.ts → attachment-types.ts} +0 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExecutionMode,
|
|
3
|
+
OwnershipDispatchContext,
|
|
4
|
+
PlanArtifactRecord,
|
|
5
|
+
PlanArtifactSubmission,
|
|
6
|
+
PlanFailureClass,
|
|
7
|
+
PlanNodeResultSubmission,
|
|
8
|
+
PlanNodeRunRecord,
|
|
9
|
+
PlanNodeSpec,
|
|
10
|
+
PlanNodeSpecRecord,
|
|
11
|
+
PlanRunRecord,
|
|
12
|
+
PlanSchemaRegistry,
|
|
13
|
+
PlanSpecRecord,
|
|
14
|
+
PlanDraft,
|
|
15
|
+
SerializableExecutionPlan,
|
|
16
|
+
} from '@lota-sdk/shared'
|
|
17
|
+
|
|
18
|
+
import { agentRoster } from '../config/agent-defaults'
|
|
19
|
+
import type { RecordIdInput } from '../db/record-id'
|
|
20
|
+
import { ensureRecordId, recordIdToString } from '../db/record-id'
|
|
21
|
+
import { databaseService } from '../db/service'
|
|
22
|
+
import { TABLES } from '../db/tables'
|
|
23
|
+
import { getRuntimeAdapters } from '../runtime/runtime-extensions'
|
|
24
|
+
import { agentExecutorService } from './agent-executor.service'
|
|
25
|
+
import { domainAgentExecutorService } from './domain-agent-executor.service'
|
|
26
|
+
import { monitoringWindowService } from './monitoring-window.service'
|
|
27
|
+
import { planExecutorService } from './plan-executor.service'
|
|
28
|
+
import { planRunService } from './plan-run.service'
|
|
29
|
+
import type { PlanValidationIssueInput } from './plan-validator.service'
|
|
30
|
+
import { pluginExecutorService } from './plugin-executor.service'
|
|
31
|
+
import { skillResolverService } from './skill-resolver.service'
|
|
32
|
+
import { systemExecutorService } from './system-executor.service'
|
|
33
|
+
import { userService } from './user.service'
|
|
34
|
+
import { WorkstreamSchema } from './workstream.types'
|
|
35
|
+
|
|
36
|
+
const STABLE_RUN_STATUSES = new Set(['awaiting-human', 'blocked', 'failed', 'completed', 'aborted'])
|
|
37
|
+
const MAX_DISPATCH_ITERATIONS = 64
|
|
38
|
+
|
|
39
|
+
function toPlanNodeSpec(nodeSpec: PlanNodeSpecRecord): PlanNodeSpec {
|
|
40
|
+
return {
|
|
41
|
+
id: nodeSpec.nodeId,
|
|
42
|
+
type: nodeSpec.type,
|
|
43
|
+
label: nodeSpec.label,
|
|
44
|
+
owner: nodeSpec.owner,
|
|
45
|
+
objective: nodeSpec.objective,
|
|
46
|
+
instructions: nodeSpec.instructions,
|
|
47
|
+
inputSchemaRef: nodeSpec.inputSchemaRef,
|
|
48
|
+
outputSchemaRef: nodeSpec.outputSchemaRef,
|
|
49
|
+
deliverables: [...nodeSpec.deliverables],
|
|
50
|
+
successCriteria: [...nodeSpec.successCriteria],
|
|
51
|
+
completionChecks: [...nodeSpec.completionChecks],
|
|
52
|
+
retryPolicy: { ...nodeSpec.retryPolicy, retryOn: [...nodeSpec.retryPolicy.retryOn] },
|
|
53
|
+
failurePolicy: [...nodeSpec.failurePolicy],
|
|
54
|
+
timeoutMs: nodeSpec.timeoutMs,
|
|
55
|
+
toolPolicy: { allow: [...nodeSpec.toolPolicy.allow], deny: [...nodeSpec.toolPolicy.deny] },
|
|
56
|
+
contextPolicy: {
|
|
57
|
+
retrievalScopes: [...nodeSpec.contextPolicy.retrievalScopes],
|
|
58
|
+
attachmentPolicy: nodeSpec.contextPolicy.attachmentPolicy,
|
|
59
|
+
webPolicy: nodeSpec.contextPolicy.webPolicy,
|
|
60
|
+
},
|
|
61
|
+
...(nodeSpec.schedule ? { schedule: nodeSpec.schedule } : {}),
|
|
62
|
+
...(nodeSpec.deadline ? { deadline: nodeSpec.deadline } : {}),
|
|
63
|
+
...(nodeSpec.monitoringConfig ? { monitoringConfig: nodeSpec.monitoringConfig } : {}),
|
|
64
|
+
...(nodeSpec.delayAfterPredecessorMs ? { delayAfterPredecessorMs: nodeSpec.delayAfterPredecessorMs } : {}),
|
|
65
|
+
...(nodeSpec.deliberationConfig ? { deliberationConfig: nodeSpec.deliberationConfig } : {}),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function toArtifactSubmission(artifact: PlanArtifactRecord): PlanArtifactSubmission {
|
|
70
|
+
return {
|
|
71
|
+
name: artifact.name,
|
|
72
|
+
kind: artifact.kind,
|
|
73
|
+
pointer: artifact.pointer,
|
|
74
|
+
...(artifact.schemaRef ? { schemaRef: artifact.schemaRef } : {}),
|
|
75
|
+
...(artifact.description ? { description: artifact.description } : {}),
|
|
76
|
+
...(artifact.payload !== undefined ? { payload: artifact.payload } : {}),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function classifyDispatchFailure(params: {
|
|
81
|
+
ownerType: PlanNodeSpec['owner']['executorType']
|
|
82
|
+
error: unknown
|
|
83
|
+
}): PlanFailureClass {
|
|
84
|
+
const errorMessage =
|
|
85
|
+
params.error instanceof Error ? params.error.message.toLowerCase() : String(params.error).toLowerCase()
|
|
86
|
+
if (errorMessage.includes('timeout')) {
|
|
87
|
+
return 'timeout_exceeded'
|
|
88
|
+
}
|
|
89
|
+
if (params.ownerType === 'plugin' || params.ownerType === 'system') {
|
|
90
|
+
return 'external_system_unavailable'
|
|
91
|
+
}
|
|
92
|
+
return 'non_recoverable_logic_error'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function formatDispatchError(error: unknown): string {
|
|
96
|
+
return error instanceof Error ? error.message : String(error)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
class OwnershipDispatcherService {
|
|
100
|
+
validateDraftExecutors(draft: PlanDraft): PlanValidationIssueInput[] {
|
|
101
|
+
const issues: PlanValidationIssueInput[] = []
|
|
102
|
+
|
|
103
|
+
for (const node of draft.nodes) {
|
|
104
|
+
if (node.owner.executorType === 'agent') {
|
|
105
|
+
if (!agentRoster.includes(node.owner.ref) && !domainAgentExecutorService.hasAgent(node.owner.ref)) {
|
|
106
|
+
issues.push({
|
|
107
|
+
severity: 'blocking',
|
|
108
|
+
code: 'agent_executor_missing',
|
|
109
|
+
message: `Node "${node.label}" references unknown agent executor "${node.owner.ref}".`,
|
|
110
|
+
nodeId: node.id,
|
|
111
|
+
detail: { agentId: node.owner.ref },
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (node.owner.executorType === 'plugin') {
|
|
118
|
+
issues.push(...pluginExecutorService.validateOwner(node.owner, node.id))
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (node.owner.executorType === 'system') {
|
|
123
|
+
issues.push(...systemExecutorService.validateOwner(node.owner, node.id))
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (node.owner.executorType === 'skill') {
|
|
128
|
+
// Skill owners are validated at execution time via skillResolverService.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return issues
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async dispatchRunToStableBoundary(params: {
|
|
136
|
+
runId: RecordIdInput
|
|
137
|
+
emittedBy: string
|
|
138
|
+
}): Promise<SerializableExecutionPlan> {
|
|
139
|
+
const initialRun = await planRunService.getRunById(params.runId)
|
|
140
|
+
const autoDispatchEnabled = await this.shouldAutoDispatch(initialRun)
|
|
141
|
+
if (!autoDispatchEnabled) {
|
|
142
|
+
return this.serializeRun(initialRun.id)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let iteration = 0
|
|
146
|
+
while (iteration < MAX_DISPATCH_ITERATIONS) {
|
|
147
|
+
const run = await planRunService.getRunById(params.runId)
|
|
148
|
+
if (STABLE_RUN_STATUSES.has(run.status) || run.status !== 'running' || !run.currentNodeId) {
|
|
149
|
+
return this.serializeRun(run.id)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const spec = await planRunService.getPlanSpecById(run.planSpecId)
|
|
153
|
+
|
|
154
|
+
if (spec.executionMode === 'graph-full') {
|
|
155
|
+
const { globalOrchestratorService } = await import('./global-orchestrator.service')
|
|
156
|
+
await globalOrchestratorService.routeGraphFull({
|
|
157
|
+
workstreamId: recordIdToString(run.workstreamId, TABLES.WORKSTREAM),
|
|
158
|
+
runId: recordIdToString(run.id, TABLES.PLAN_RUN),
|
|
159
|
+
})
|
|
160
|
+
return this.serializeRun(run.id)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const nodeSpecRecord = await planRunService.getNodeSpecByNodeId(spec.id, run.currentNodeId)
|
|
164
|
+
const planNode = toPlanNodeSpec(nodeSpecRecord)
|
|
165
|
+
if (planNode.owner.executorType === 'user') {
|
|
166
|
+
return this.serializeRun(run.id)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const nodeRun = await planRunService.getNodeRunByNodeId(run.id, nodeSpecRecord.nodeId)
|
|
170
|
+
if (nodeRun.status === 'monitoring') {
|
|
171
|
+
// Monitoring nodes are managed by the scheduler — treat as stable
|
|
172
|
+
return this.serializeRun(run.id)
|
|
173
|
+
}
|
|
174
|
+
if (nodeRun.status !== 'running') {
|
|
175
|
+
return this.serializeRun(run.id)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const [artifacts, dispatchContext] = await Promise.all([
|
|
179
|
+
planRunService.listArtifacts(run.id),
|
|
180
|
+
this.buildDispatchContext(run),
|
|
181
|
+
])
|
|
182
|
+
const inputArtifacts = artifacts
|
|
183
|
+
.filter((artifact) => nodeSpecRecord.upstreamNodeIds.includes(artifact.nodeId))
|
|
184
|
+
.map((artifact) => toArtifactSubmission(artifact))
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const result = await this.dispatchNode({
|
|
188
|
+
nodeSpec: planNode,
|
|
189
|
+
resolvedInput: nodeRun.resolvedInput ?? {},
|
|
190
|
+
inputArtifacts,
|
|
191
|
+
context: { ...dispatchContext, nodeId: planNode.id },
|
|
192
|
+
executionMode: spec.executionMode,
|
|
193
|
+
schemaRegistry: spec.schemaRegistry,
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
await planExecutorService.submitNodeResult({
|
|
197
|
+
workstreamId: run.workstreamId,
|
|
198
|
+
runId: recordIdToString(run.id, TABLES.PLAN_RUN),
|
|
199
|
+
nodeId: planNode.id,
|
|
200
|
+
emittedBy: planNode.owner.ref,
|
|
201
|
+
result,
|
|
202
|
+
})
|
|
203
|
+
} catch (error) {
|
|
204
|
+
await planExecutorService.blockNodeOnDispatchFailure({
|
|
205
|
+
workstreamId: run.workstreamId,
|
|
206
|
+
runId: recordIdToString(run.id, TABLES.PLAN_RUN),
|
|
207
|
+
nodeId: planNode.id,
|
|
208
|
+
emittedBy: planNode.owner.ref,
|
|
209
|
+
message: formatDispatchError(error),
|
|
210
|
+
failureClass: classifyDispatchFailure({ ownerType: planNode.owner.executorType, error }),
|
|
211
|
+
})
|
|
212
|
+
return await this.serializeRun(run.id)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
iteration += 1
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
throw new Error(
|
|
219
|
+
`Ownership dispatch exceeded ${MAX_DISPATCH_ITERATIONS} iterations for run ${recordIdToString(
|
|
220
|
+
ensureRecordId(params.runId, TABLES.PLAN_RUN),
|
|
221
|
+
TABLES.PLAN_RUN,
|
|
222
|
+
)}.`,
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async dispatchReadyNode(params: {
|
|
227
|
+
run: PlanRunRecord
|
|
228
|
+
nodeSpecRecord: PlanNodeSpecRecord
|
|
229
|
+
nodeRun: PlanNodeRunRecord
|
|
230
|
+
spec: PlanSpecRecord
|
|
231
|
+
executionModeOverride?: ExecutionMode
|
|
232
|
+
}): Promise<PlanNodeResultSubmission> {
|
|
233
|
+
const planNode = toPlanNodeSpec(params.nodeSpecRecord)
|
|
234
|
+
const [artifacts, dispatchContext] = await Promise.all([
|
|
235
|
+
planRunService.listArtifacts(params.run.id),
|
|
236
|
+
this.buildDispatchContext(params.run),
|
|
237
|
+
])
|
|
238
|
+
const inputArtifacts = artifacts
|
|
239
|
+
.filter((artifact) => params.nodeSpecRecord.upstreamNodeIds.includes(artifact.nodeId))
|
|
240
|
+
.map((artifact) => toArtifactSubmission(artifact))
|
|
241
|
+
|
|
242
|
+
return this.dispatchNode({
|
|
243
|
+
nodeSpec: planNode,
|
|
244
|
+
resolvedInput: params.nodeRun.resolvedInput ?? {},
|
|
245
|
+
inputArtifacts,
|
|
246
|
+
context: { ...dispatchContext, nodeId: planNode.id },
|
|
247
|
+
executionMode: params.spec.executionMode,
|
|
248
|
+
executionModeOverride: params.executionModeOverride,
|
|
249
|
+
schemaRegistry: params.spec.schemaRegistry,
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private async shouldAutoDispatch(run: PlanRunRecord): Promise<boolean> {
|
|
254
|
+
const workspaceProvider = getRuntimeAdapters().services?.workspaceProvider
|
|
255
|
+
if (!workspaceProvider) {
|
|
256
|
+
return true
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const workspace = await workspaceProvider.getWorkspace(ensureRecordId(run.organizationId, TABLES.ORGANIZATION))
|
|
260
|
+
const lifecycleState = await workspaceProvider.getLifecycleState?.(workspace)
|
|
261
|
+
return lifecycleState?.bootstrapActive !== true
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private async buildDispatchContext(run: PlanRunRecord): Promise<Omit<OwnershipDispatchContext, 'nodeId'>> {
|
|
265
|
+
const organizationId = recordIdToString(run.organizationId, TABLES.ORGANIZATION)
|
|
266
|
+
const workstreamId = recordIdToString(run.workstreamId, TABLES.WORKSTREAM)
|
|
267
|
+
const planId = recordIdToString(run.id, TABLES.PLAN_RUN)
|
|
268
|
+
const workstream = await databaseService.findOne(
|
|
269
|
+
TABLES.WORKSTREAM,
|
|
270
|
+
{ id: ensureRecordId(run.workstreamId, TABLES.WORKSTREAM) },
|
|
271
|
+
WorkstreamSchema,
|
|
272
|
+
)
|
|
273
|
+
const userId = workstream?.userId ? recordIdToString(workstream.userId, TABLES.USER) : undefined
|
|
274
|
+
const userName = userId
|
|
275
|
+
? await userService
|
|
276
|
+
.getUser(userId)
|
|
277
|
+
.then((user) => user.name)
|
|
278
|
+
.catch(() => undefined)
|
|
279
|
+
: undefined
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
organizationId,
|
|
283
|
+
workstreamId,
|
|
284
|
+
planId,
|
|
285
|
+
leadAgentId: run.leadAgentId,
|
|
286
|
+
...(userId ? { userId } : {}),
|
|
287
|
+
...(userName ? { userName } : {}),
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private async serializeRun(runId: RecordIdInput): Promise<SerializableExecutionPlan> {
|
|
292
|
+
return planRunService.toSerializablePlan(await planRunService.getRunById(runId), {
|
|
293
|
+
includeEvents: true,
|
|
294
|
+
includeArtifacts: true,
|
|
295
|
+
includeApprovals: true,
|
|
296
|
+
includeCheckpoints: true,
|
|
297
|
+
includeValidationIssues: true,
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async dispatchNode(params: {
|
|
302
|
+
nodeSpec: PlanNodeSpec
|
|
303
|
+
resolvedInput: Record<string, unknown>
|
|
304
|
+
inputArtifacts: PlanArtifactSubmission[]
|
|
305
|
+
context: OwnershipDispatchContext
|
|
306
|
+
executionMode?: ExecutionMode
|
|
307
|
+
executionModeOverride?: ExecutionMode
|
|
308
|
+
schemaRegistry?: PlanSchemaRegistry
|
|
309
|
+
}) {
|
|
310
|
+
const effectiveExecutionMode = params.executionModeOverride ?? params.executionMode
|
|
311
|
+
if (params.nodeSpec.type === 'monitoring' && params.nodeSpec.monitoringConfig) {
|
|
312
|
+
await monitoringWindowService.startMonitoringWindow({
|
|
313
|
+
runId: params.context.planId,
|
|
314
|
+
nodeId: params.nodeSpec.id,
|
|
315
|
+
config: params.nodeSpec.monitoringConfig,
|
|
316
|
+
organizationId: params.context.organizationId,
|
|
317
|
+
workstreamId: params.context.workstreamId,
|
|
318
|
+
})
|
|
319
|
+
return {
|
|
320
|
+
structuredOutput: { status: 'monitoring-started', config: params.nodeSpec.monitoringConfig },
|
|
321
|
+
artifacts: [],
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (params.nodeSpec.owner.executorType === 'agent') {
|
|
326
|
+
if (domainAgentExecutorService.hasAgent(params.nodeSpec.owner.ref)) {
|
|
327
|
+
return domainAgentExecutorService.executeNode({
|
|
328
|
+
nodeSpec: params.nodeSpec,
|
|
329
|
+
resolvedInput: params.resolvedInput,
|
|
330
|
+
inputArtifacts: params.inputArtifacts,
|
|
331
|
+
context: params.context,
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
return agentExecutorService.executeNode({
|
|
335
|
+
nodeSpec: params.nodeSpec,
|
|
336
|
+
resolvedInput: params.resolvedInput,
|
|
337
|
+
inputArtifacts: params.inputArtifacts,
|
|
338
|
+
context: params.context,
|
|
339
|
+
executionMode: effectiveExecutionMode,
|
|
340
|
+
schemaRegistry: params.schemaRegistry,
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
if (params.nodeSpec.owner.executorType === 'plugin') {
|
|
344
|
+
return pluginExecutorService.executeNode({
|
|
345
|
+
nodeSpec: params.nodeSpec,
|
|
346
|
+
resolvedInput: params.resolvedInput,
|
|
347
|
+
context: params.context,
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
if (params.nodeSpec.owner.executorType === 'system') {
|
|
351
|
+
return systemExecutorService.executeNode({
|
|
352
|
+
nodeSpec: params.nodeSpec,
|
|
353
|
+
resolvedInput: params.resolvedInput,
|
|
354
|
+
context: params.context,
|
|
355
|
+
})
|
|
356
|
+
}
|
|
357
|
+
if (params.nodeSpec.owner.executorType === 'skill') {
|
|
358
|
+
const resolved = await skillResolverService.resolve({
|
|
359
|
+
skillRef: params.nodeSpec.owner.ref,
|
|
360
|
+
organizationId: params.context.organizationId,
|
|
361
|
+
})
|
|
362
|
+
if (!resolved) {
|
|
363
|
+
throw new Error(`Skill "${params.nodeSpec.owner.ref}" could not be resolved. This is a configuration error.`)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (resolved.executorType === 'agent') {
|
|
367
|
+
const skillNodeSpec = { ...params.nodeSpec, owner: { executorType: 'agent' as const, ref: resolved.ref } }
|
|
368
|
+
if (domainAgentExecutorService.hasAgent(resolved.ref)) {
|
|
369
|
+
return domainAgentExecutorService.executeNode({
|
|
370
|
+
nodeSpec: skillNodeSpec,
|
|
371
|
+
resolvedInput: params.resolvedInput,
|
|
372
|
+
inputArtifacts: params.inputArtifacts,
|
|
373
|
+
context: params.context,
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
return agentExecutorService.executeNode({
|
|
377
|
+
nodeSpec: skillNodeSpec,
|
|
378
|
+
resolvedInput: params.resolvedInput,
|
|
379
|
+
inputArtifacts: params.inputArtifacts,
|
|
380
|
+
context: params.context,
|
|
381
|
+
executionMode: effectiveExecutionMode,
|
|
382
|
+
schemaRegistry: params.schemaRegistry,
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
return pluginExecutorService.executeNode({
|
|
386
|
+
nodeSpec: {
|
|
387
|
+
...params.nodeSpec,
|
|
388
|
+
owner: {
|
|
389
|
+
executorType: 'plugin' as const,
|
|
390
|
+
ref: resolved.ref,
|
|
391
|
+
operation: resolved.operation ?? params.nodeSpec.owner.ref,
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
resolvedInput: params.resolvedInput,
|
|
395
|
+
context: params.context,
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
throw new Error(`User-owned node "${params.nodeSpec.id}" cannot be auto-dispatched.`)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export const ownershipDispatcherService = new OwnershipDispatcherService()
|
|
@@ -35,7 +35,7 @@ class PlanApprovalService {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
async getApprovalById(approvalId: RecordIdInput): Promise<PlanApprovalRecord | null> {
|
|
38
|
-
return
|
|
38
|
+
return databaseService.findOne(
|
|
39
39
|
TABLES.PLAN_APPROVAL,
|
|
40
40
|
{ id: ensureRecordId(approvalId, TABLES.PLAN_APPROVAL) },
|
|
41
41
|
PlanApprovalSchema,
|
|
@@ -17,6 +17,7 @@ class PlanArtifactService {
|
|
|
17
17
|
}): Promise<PlanArtifactRecord[]> {
|
|
18
18
|
const records: PlanArtifactRecord[] = []
|
|
19
19
|
|
|
20
|
+
// Sequential: SurrealDB transactions require ordered operations
|
|
20
21
|
for (const artifact of params.artifacts) {
|
|
21
22
|
const artifactId = new RecordId(TABLES.PLAN_ARTIFACT, Bun.randomUUIDv7())
|
|
22
23
|
const created = await params.tx
|
|
@@ -23,6 +23,7 @@ class PlanBuilderService {
|
|
|
23
23
|
structureDesign(draft: PlanDraft): PlanDraft {
|
|
24
24
|
return {
|
|
25
25
|
...draft,
|
|
26
|
+
executionMode: draft.executionMode ?? 'linear',
|
|
26
27
|
edges: buildImplicitLinearEdges(draft),
|
|
27
28
|
entryNodeIds: draft.entryNodeIds && draft.entryNodeIds.length > 0 ? draft.entryNodeIds : [draft.nodes[0].id],
|
|
28
29
|
}
|
|
@@ -2,11 +2,13 @@ import { PlanCheckpointSchema } from '@lota-sdk/shared'
|
|
|
2
2
|
import type { PlanCheckpointRecord, PlanRunStatus } from '@lota-sdk/shared'
|
|
3
3
|
import { RecordId } from 'surrealdb'
|
|
4
4
|
|
|
5
|
+
import { serverLogger } from '../config/logger'
|
|
5
6
|
import type { RecordIdInput } from '../db/record-id'
|
|
6
|
-
import { ensureRecordId } from '../db/record-id'
|
|
7
|
+
import { ensureRecordId, recordIdToString } from '../db/record-id'
|
|
7
8
|
import { databaseService } from '../db/service'
|
|
8
9
|
import type { DatabaseTransaction } from '../db/service'
|
|
9
10
|
import { TABLES } from '../db/tables'
|
|
11
|
+
import { planWorkspaceService } from './plan-workspace.service'
|
|
10
12
|
|
|
11
13
|
class PlanCheckpointService {
|
|
12
14
|
async createCheckpoint(params: {
|
|
@@ -19,7 +21,33 @@ class PlanCheckpointService {
|
|
|
19
21
|
artifactIds: RecordIdInput[]
|
|
20
22
|
lastCompletedNodeIds: string[]
|
|
21
23
|
snapshot: Record<string, unknown>
|
|
24
|
+
includeWorkspace?: boolean
|
|
22
25
|
}): Promise<PlanCheckpointRecord> {
|
|
26
|
+
const snapshotData = { ...params.snapshot }
|
|
27
|
+
|
|
28
|
+
if (params.includeWorkspace) {
|
|
29
|
+
const runIdStr = recordIdToString(params.runId, TABLES.PLAN_RUN)
|
|
30
|
+
const workspaceSnapshot = await planWorkspaceService
|
|
31
|
+
.currentRead({ runId: runIdStr })
|
|
32
|
+
.then((all) => {
|
|
33
|
+
// Filter to entries at or before the checkpoint sequence
|
|
34
|
+
const filtered: Record<string, unknown> = {}
|
|
35
|
+
for (const [key, entry] of Object.entries(all)) {
|
|
36
|
+
if (entry.writeSequence <= params.sequence) {
|
|
37
|
+
filtered[key] = entry
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return filtered
|
|
41
|
+
})
|
|
42
|
+
.catch((error) => {
|
|
43
|
+
serverLogger.warn`Workspace snapshot failed for run ${runIdStr}: ${error}`
|
|
44
|
+
return {}
|
|
45
|
+
})
|
|
46
|
+
if (Object.keys(workspaceSnapshot).length > 0) {
|
|
47
|
+
snapshotData.workspaceSnapshot = workspaceSnapshot
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
23
51
|
const checkpointId = new RecordId(TABLES.PLAN_CHECKPOINT, Bun.randomUUIDv7())
|
|
24
52
|
const created = await params.tx
|
|
25
53
|
.create(checkpointId)
|
|
@@ -31,7 +59,7 @@ class PlanCheckpointService {
|
|
|
31
59
|
activeNodeIds: [...params.activeNodeIds],
|
|
32
60
|
artifactIds: params.artifactIds.map((artifactId) => ensureRecordId(artifactId, TABLES.PLAN_ARTIFACT)),
|
|
33
61
|
lastCompletedNodeIds: [...params.lastCompletedNodeIds],
|
|
34
|
-
snapshot:
|
|
62
|
+
snapshot: snapshotData,
|
|
35
63
|
})
|
|
36
64
|
.output('after')
|
|
37
65
|
|
|
@@ -72,6 +72,11 @@ class PlanCompilerService {
|
|
|
72
72
|
attachmentPolicy: node.contextPolicy.attachmentPolicy,
|
|
73
73
|
webPolicy: node.contextPolicy.webPolicy,
|
|
74
74
|
},
|
|
75
|
+
...(node.schedule ? { schedule: node.schedule } : {}),
|
|
76
|
+
...(node.deadline ? { deadline: node.deadline } : {}),
|
|
77
|
+
...(node.monitoringConfig ? { monitoringConfig: node.monitoringConfig } : {}),
|
|
78
|
+
...(node.delayAfterPredecessorMs ? { delayAfterPredecessorMs: node.delayAfterPredecessorMs } : {}),
|
|
79
|
+
...(node.deliberationConfig ? { deliberationConfig: node.deliberationConfig } : {}),
|
|
75
80
|
upstreamNodeIds,
|
|
76
81
|
downstreamNodeIds,
|
|
77
82
|
}))
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { PlanArtifactRecord, PlanDependency } from '@lota-sdk/shared'
|
|
2
|
+
|
|
3
|
+
import { serverLogger } from '../config/logger'
|
|
4
|
+
import type { PlanValidationIssueInput } from './plan-validator.service'
|
|
5
|
+
|
|
6
|
+
export interface DependencyResolutionResult {
|
|
7
|
+
resolved: Map<string, PlanArtifactRecord>
|
|
8
|
+
unresolved: PlanDependency[]
|
|
9
|
+
notifications: Array<{ dependency: PlanDependency; reason: string }>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
class PlanCoordinationService {
|
|
13
|
+
/**
|
|
14
|
+
* Resolve cross-plan artifact dependencies.
|
|
15
|
+
* For each dependency:
|
|
16
|
+
* 1. Find the source plan by title in the workstream
|
|
17
|
+
* 2. Find the artifact by (nodeId, artifactName) in that plan's run
|
|
18
|
+
* 3. Check staleness if maxStalenessMs set
|
|
19
|
+
* 4. Based on triggerMode:
|
|
20
|
+
* 'block' -> unresolved if missing/stale
|
|
21
|
+
* 'notify' -> proceed but record notification
|
|
22
|
+
* 'best-effort' -> proceed regardless, no notification
|
|
23
|
+
*/
|
|
24
|
+
async resolveDependencies(params: {
|
|
25
|
+
dependencies: PlanDependency[]
|
|
26
|
+
workstreamId: string
|
|
27
|
+
}): Promise<DependencyResolutionResult> {
|
|
28
|
+
const { planRunService } = await import('./plan-run.service')
|
|
29
|
+
|
|
30
|
+
const resolved = new Map<string, PlanArtifactRecord>()
|
|
31
|
+
const unresolved: PlanDependency[] = []
|
|
32
|
+
const notifications: DependencyResolutionResult['notifications'] = []
|
|
33
|
+
|
|
34
|
+
for (const dep of params.dependencies) {
|
|
35
|
+
const depKey = `${dep.sourcePlanTitle}:${dep.sourceNodeId}:${dep.artifactName}`
|
|
36
|
+
|
|
37
|
+
const specs = await planRunService.listPlanSpecsByWorkstream(params.workstreamId)
|
|
38
|
+
const sourceSpec = specs.find((s) => s.title === dep.sourcePlanTitle)
|
|
39
|
+
if (!sourceSpec) {
|
|
40
|
+
const reason = `Source plan "${dep.sourcePlanTitle}" not found in workstream.`
|
|
41
|
+
if (dep.triggerMode === 'block') {
|
|
42
|
+
unresolved.push(dep)
|
|
43
|
+
} else if (dep.triggerMode === 'notify') {
|
|
44
|
+
notifications.push({ dependency: dep, reason })
|
|
45
|
+
serverLogger.warn`Dependency unmet (notify): ${reason}`
|
|
46
|
+
}
|
|
47
|
+
// best-effort: silently proceed
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const runs = await planRunService.listRunsBySpec(sourceSpec.id)
|
|
52
|
+
const activeRun = runs.find((r) => r.status === 'completed' || r.status === 'running')
|
|
53
|
+
if (!activeRun) {
|
|
54
|
+
const reason = `No active run found for plan "${dep.sourcePlanTitle}".`
|
|
55
|
+
if (dep.triggerMode === 'block') {
|
|
56
|
+
unresolved.push(dep)
|
|
57
|
+
} else if (dep.triggerMode === 'notify') {
|
|
58
|
+
notifications.push({ dependency: dep, reason })
|
|
59
|
+
serverLogger.warn`Dependency unmet (notify): ${reason}`
|
|
60
|
+
}
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const artifacts = await planRunService.listArtifacts(activeRun.id)
|
|
65
|
+
const artifact = artifacts.find((a) => a.nodeId === dep.sourceNodeId && a.name === dep.artifactName)
|
|
66
|
+
|
|
67
|
+
if (!artifact) {
|
|
68
|
+
const reason = `Artifact "${dep.artifactName}" not found on node "${dep.sourceNodeId}" in plan "${dep.sourcePlanTitle}".`
|
|
69
|
+
if (dep.triggerMode === 'block') {
|
|
70
|
+
unresolved.push(dep)
|
|
71
|
+
} else if (dep.triggerMode === 'notify') {
|
|
72
|
+
notifications.push({ dependency: dep, reason })
|
|
73
|
+
serverLogger.warn`Dependency unmet (notify): ${reason}`
|
|
74
|
+
}
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (dep.maxStalenessMs && this.isStale(artifact, dep.maxStalenessMs)) {
|
|
79
|
+
const reason = `Artifact "${dep.artifactName}" from plan "${dep.sourcePlanTitle}" is stale.`
|
|
80
|
+
if (dep.triggerMode === 'block') {
|
|
81
|
+
unresolved.push(dep)
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
if (dep.triggerMode === 'notify') {
|
|
85
|
+
notifications.push({ dependency: dep, reason })
|
|
86
|
+
serverLogger.warn`Dependency stale (notify): ${reason}`
|
|
87
|
+
}
|
|
88
|
+
// best-effort and notify: use stale artifact anyway
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
resolved.set(depKey, artifact)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { resolved, unresolved, notifications }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Check if an artifact has exceeded the staleness window. */
|
|
98
|
+
isStale(artifact: Pick<PlanArtifactRecord, 'createdAt'>, maxStalenessMs: number): boolean {
|
|
99
|
+
if (!maxStalenessMs) return false
|
|
100
|
+
return Date.now() - artifact.createdAt.getTime() > maxStalenessMs
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Validate no circular dependencies exist using Kahn's algorithm.
|
|
105
|
+
* Build adjacency: planTitle -> depends on planTitles
|
|
106
|
+
* Run topological sort; if not all visited -> cycle exists.
|
|
107
|
+
*/
|
|
108
|
+
validateNoCycles(specs: Array<{ title: string; dependencies?: PlanDependency[] }>): PlanValidationIssueInput[] {
|
|
109
|
+
const adj = new Map<string, Set<string>>()
|
|
110
|
+
const inDegree = new Map<string, number>()
|
|
111
|
+
|
|
112
|
+
for (const spec of specs) {
|
|
113
|
+
if (!adj.has(spec.title)) adj.set(spec.title, new Set())
|
|
114
|
+
if (!inDegree.has(spec.title)) inDegree.set(spec.title, 0)
|
|
115
|
+
|
|
116
|
+
for (const dep of spec.dependencies ?? []) {
|
|
117
|
+
if (!adj.has(dep.sourcePlanTitle)) adj.set(dep.sourcePlanTitle, new Set())
|
|
118
|
+
if (!inDegree.has(dep.sourcePlanTitle)) inDegree.set(dep.sourcePlanTitle, 0)
|
|
119
|
+
|
|
120
|
+
adj.get(dep.sourcePlanTitle)?.add(spec.title)
|
|
121
|
+
inDegree.set(spec.title, (inDegree.get(spec.title) ?? 0) + 1)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const queue = [...inDegree.entries()].filter(([, d]) => d === 0).map(([t]) => t)
|
|
126
|
+
const visited = new Set<string>()
|
|
127
|
+
|
|
128
|
+
while (queue.length > 0) {
|
|
129
|
+
const node = queue.shift()
|
|
130
|
+
if (!node) break
|
|
131
|
+
visited.add(node)
|
|
132
|
+
for (const dep of adj.get(node) ?? []) {
|
|
133
|
+
const d = (inDegree.get(dep) ?? 0) - 1
|
|
134
|
+
inDegree.set(dep, d)
|
|
135
|
+
if (d === 0) queue.push(dep)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const unvisited = specs.filter((s) => !visited.has(s.title))
|
|
140
|
+
if (unvisited.length === 0) return []
|
|
141
|
+
|
|
142
|
+
return [
|
|
143
|
+
{
|
|
144
|
+
severity: 'blocking',
|
|
145
|
+
code: 'circular_dependency',
|
|
146
|
+
message: `Circular plan dependencies detected involving: ${unvisited.map((s) => s.title).join(', ')}`,
|
|
147
|
+
},
|
|
148
|
+
]
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const planCoordinationService = new PlanCoordinationService()
|