@lota-sdk/core 0.1.8 → 0.1.11
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_workstream.surql +2 -1
- package/infrastructure/schema/02_execution_plan.surql +202 -52
- package/package.json +4 -2
- package/src/bifrost/bifrost.ts +94 -25
- package/src/config/model-constants.ts +8 -6
- package/src/db/memory-store.ts +3 -71
- package/src/db/service.ts +42 -2
- package/src/db/tables.ts +9 -2
- package/src/embeddings/provider.ts +92 -21
- package/src/index.ts +6 -0
- package/src/redis/stream-context.ts +44 -0
- package/src/runtime/approval-continuation.ts +59 -0
- package/src/runtime/chat-request-routing.ts +5 -1
- package/src/runtime/execution-plan.ts +21 -14
- package/src/runtime/turn-lifecycle.ts +14 -6
- package/src/runtime/workstream-chat-helpers.ts +5 -5
- package/src/services/context-compaction.service.ts +6 -2
- package/src/services/document-chunk.service.ts +2 -2
- package/src/services/execution-plan.service.ts +579 -786
- package/src/services/learned-skill.service.ts +2 -2
- package/src/services/plan-approval.service.ts +83 -0
- package/src/services/plan-artifact.service.ts +45 -0
- package/src/services/plan-builder.service.ts +61 -0
- package/src/services/plan-checkpoint.service.ts +53 -0
- package/src/services/plan-compiler.service.ts +81 -0
- package/src/services/plan-executor.service.ts +1623 -0
- package/src/services/plan-run.service.ts +422 -0
- package/src/services/plan-validator.service.ts +760 -0
- package/src/services/workstream-turn-preparation.ts +70 -196
- package/src/services/workstream-turn.ts +12 -0
- package/src/services/workstream.service.ts +24 -182
- package/src/services/workstream.types.ts +2 -65
- package/src/system-agents/title-generator.agent.ts +2 -2
- package/src/tools/execution-plan.tool.ts +20 -46
- package/src/tools/log-hello-world.tool.ts +17 -0
- package/src/workers/skill-extraction.runner.ts +2 -2
- package/src/services/workstream-change-tracker.service.ts +0 -313
- package/src/system-agents/workstream-tracker.agent.ts +0 -58
|
@@ -0,0 +1,1623 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PlanEventSchema,
|
|
3
|
+
PlanNodeAttemptSchema,
|
|
4
|
+
PlanNodeRunSchema,
|
|
5
|
+
PlanRunSchema,
|
|
6
|
+
PlanValidationIssueSchema,
|
|
7
|
+
} from '@lota-sdk/shared/schemas/execution-plan'
|
|
8
|
+
import type {
|
|
9
|
+
PlanEventRecord,
|
|
10
|
+
PlanEventType,
|
|
11
|
+
PlanFailureAction,
|
|
12
|
+
PlanFailureClass,
|
|
13
|
+
PlanNodeRunRecord,
|
|
14
|
+
PlanNodeSpecRecord,
|
|
15
|
+
PlanRunRecord,
|
|
16
|
+
PlanSpecRecord,
|
|
17
|
+
PlanValidationIssueRecord,
|
|
18
|
+
SerializableExecutionPlan,
|
|
19
|
+
} from '@lota-sdk/shared/schemas/execution-plan'
|
|
20
|
+
import type { ExecutionPlanToolResultData, PlanNodeResultSubmission } from '@lota-sdk/shared/schemas/tools'
|
|
21
|
+
import { RecordId } from 'surrealdb'
|
|
22
|
+
|
|
23
|
+
import type { RecordIdInput } from '../db/record-id'
|
|
24
|
+
import { ensureRecordId, recordIdToString } from '../db/record-id'
|
|
25
|
+
import { databaseService } from '../db/service'
|
|
26
|
+
import type { DatabaseTransaction } from '../db/service'
|
|
27
|
+
import { TABLES } from '../db/tables'
|
|
28
|
+
import { planApprovalService } from './plan-approval.service'
|
|
29
|
+
import { planArtifactService } from './plan-artifact.service'
|
|
30
|
+
import { planCheckpointService } from './plan-checkpoint.service'
|
|
31
|
+
import { planRunService } from './plan-run.service'
|
|
32
|
+
import type { PlanValidationIssueInput } from './plan-validator.service'
|
|
33
|
+
import { planValidatorService } from './plan-validator.service'
|
|
34
|
+
|
|
35
|
+
const SUCCESSFUL_TERMINAL_NODE_STATUSES = new Set(['completed', 'partial', 'skipped'])
|
|
36
|
+
const HUMAN_NODE_TYPES = new Set(['human-input', 'human-approval', 'human-review-edit', 'human-decision'])
|
|
37
|
+
const STRUCTURAL_NODE_TYPES = new Set(['switch', 'join'])
|
|
38
|
+
|
|
39
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
40
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readPathValue(source: unknown, path: string): unknown {
|
|
44
|
+
if (!path.trim()) return source
|
|
45
|
+
|
|
46
|
+
let current: unknown = source
|
|
47
|
+
for (const segment of path
|
|
48
|
+
.split('.')
|
|
49
|
+
.map((part) => part.trim())
|
|
50
|
+
.filter(Boolean)) {
|
|
51
|
+
if (!isRecord(current)) return undefined
|
|
52
|
+
current = current[segment]
|
|
53
|
+
}
|
|
54
|
+
return current
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function setPathValue(target: Record<string, unknown>, path: string, value: unknown) {
|
|
58
|
+
const segments = path
|
|
59
|
+
.split('.')
|
|
60
|
+
.map((part) => part.trim())
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
if (segments.length === 0) return
|
|
63
|
+
const lastSegment = segments.at(-1)
|
|
64
|
+
if (!lastSegment) return
|
|
65
|
+
|
|
66
|
+
let current: Record<string, unknown> = target
|
|
67
|
+
for (const segment of segments.slice(0, -1)) {
|
|
68
|
+
const next = current[segment]
|
|
69
|
+
if (!isRecord(next)) {
|
|
70
|
+
current[segment] = {}
|
|
71
|
+
}
|
|
72
|
+
current = current[segment] as Record<string, unknown>
|
|
73
|
+
}
|
|
74
|
+
current[lastSegment] = value
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseLiteralValue(raw: string): unknown {
|
|
78
|
+
const trimmed = raw.trim()
|
|
79
|
+
if (!trimmed.length) return undefined
|
|
80
|
+
if (trimmed === 'true') return true
|
|
81
|
+
if (trimmed === 'false') return false
|
|
82
|
+
if (trimmed === 'null') return null
|
|
83
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed)
|
|
84
|
+
if (
|
|
85
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
86
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
|
|
87
|
+
(trimmed.startsWith('`') && trimmed.endsWith('`'))
|
|
88
|
+
) {
|
|
89
|
+
return trimmed.slice(1, -1)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(trimmed)
|
|
94
|
+
} catch {
|
|
95
|
+
return trimmed
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function buildArtifactContext(
|
|
100
|
+
artifacts: Array<{ name: string; kind: string; pointer: string; schemaRef?: string; payload?: unknown }>,
|
|
101
|
+
) {
|
|
102
|
+
return Object.fromEntries(
|
|
103
|
+
artifacts.map((artifact) => [
|
|
104
|
+
artifact.name,
|
|
105
|
+
{ kind: artifact.kind, pointer: artifact.pointer, schemaRef: artifact.schemaRef, payload: artifact.payload },
|
|
106
|
+
]),
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function buildNodeContext(params: {
|
|
111
|
+
nodeRun: PlanNodeRunRecord | undefined
|
|
112
|
+
artifacts: Array<{ name: string; kind: string; pointer: string; schemaRef?: string; payload?: unknown }>
|
|
113
|
+
}) {
|
|
114
|
+
return {
|
|
115
|
+
input: params.nodeRun?.resolvedInput ?? {},
|
|
116
|
+
output: params.nodeRun?.latestStructuredOutput ?? {},
|
|
117
|
+
artifact: buildArtifactContext(params.artifacts),
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function evaluateCondition(expression: string | undefined, context: Record<string, unknown>): boolean {
|
|
122
|
+
if (!expression?.trim()) return true
|
|
123
|
+
const normalized = expression.trim()
|
|
124
|
+
if (normalized === 'always') return true
|
|
125
|
+
|
|
126
|
+
const match = normalized.match(/^([a-zA-Z0-9_.]+)\s*(==|!=|>=|<=|>|<)\s*(.+)$/)
|
|
127
|
+
if (!match) {
|
|
128
|
+
return Boolean(readPathValue(context, normalized))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const [, leftPath, operator, rawRightValue] = match
|
|
132
|
+
const left = readPathValue(context, leftPath)
|
|
133
|
+
const right = parseLiteralValue(rawRightValue)
|
|
134
|
+
|
|
135
|
+
if (operator === '==') return Object.is(left, right)
|
|
136
|
+
if (operator === '!=') return !Object.is(left, right)
|
|
137
|
+
if (typeof left !== 'number' || typeof right !== 'number') return false
|
|
138
|
+
if (operator === '>=') return left >= right
|
|
139
|
+
if (operator === '<=') return left <= right
|
|
140
|
+
if (operator === '>') return left > right
|
|
141
|
+
return left < right
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isSuccessfulTerminalStatus(status: string): boolean {
|
|
145
|
+
return SUCCESSFUL_TERMINAL_NODE_STATUSES.has(status)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isHumanNodeType(type: string): boolean {
|
|
149
|
+
return HUMAN_NODE_TYPES.has(type)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isStructuralNodeType(type: string): boolean {
|
|
153
|
+
return STRUCTURAL_NODE_TYPES.has(type)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
type PlanRunUpdate = Omit<
|
|
157
|
+
Partial<PlanRunRecord>,
|
|
158
|
+
'currentNodeId' | 'waitingNodeId' | 'replacedRunId' | 'lastCheckpointId' | 'startedAt' | 'completedAt'
|
|
159
|
+
> & {
|
|
160
|
+
currentNodeId?: string | null
|
|
161
|
+
waitingNodeId?: string | null
|
|
162
|
+
replacedRunId?: RecordIdInput | null
|
|
163
|
+
lastCheckpointId?: RecordIdInput | null
|
|
164
|
+
startedAt?: Date | null
|
|
165
|
+
completedAt?: Date | null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
type PlanNodeRunUpdate = Omit<
|
|
169
|
+
Partial<PlanNodeRunRecord>,
|
|
170
|
+
| 'blockedReason'
|
|
171
|
+
| 'failureClass'
|
|
172
|
+
| 'resolvedInput'
|
|
173
|
+
| 'latestStructuredOutput'
|
|
174
|
+
| 'latestNotes'
|
|
175
|
+
| 'latestAttemptId'
|
|
176
|
+
| 'readyAt'
|
|
177
|
+
| 'startedAt'
|
|
178
|
+
| 'completedAt'
|
|
179
|
+
> & {
|
|
180
|
+
blockedReason?: string | null
|
|
181
|
+
failureClass?: PlanFailureClass | null
|
|
182
|
+
resolvedInput?: Record<string, unknown> | null
|
|
183
|
+
latestStructuredOutput?: Record<string, unknown> | null
|
|
184
|
+
latestNotes?: string | null
|
|
185
|
+
latestAttemptId?: RecordIdInput | null
|
|
186
|
+
readyAt?: Date | null
|
|
187
|
+
startedAt?: Date | null
|
|
188
|
+
completedAt?: Date | null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function toRunData(run: PlanRunRecord, patch: PlanRunUpdate) {
|
|
192
|
+
return {
|
|
193
|
+
planSpecId: ensureRecordId(run.planSpecId, TABLES.PLAN_SPEC),
|
|
194
|
+
organizationId: ensureRecordId(run.organizationId, TABLES.ORGANIZATION),
|
|
195
|
+
workstreamId: ensureRecordId(run.workstreamId, TABLES.WORKSTREAM),
|
|
196
|
+
leadAgentId: patch.leadAgentId ?? run.leadAgentId,
|
|
197
|
+
status: patch.status ?? run.status,
|
|
198
|
+
...(patch.currentNodeId === null
|
|
199
|
+
? {}
|
|
200
|
+
: patch.currentNodeId !== undefined
|
|
201
|
+
? { currentNodeId: patch.currentNodeId }
|
|
202
|
+
: run.currentNodeId
|
|
203
|
+
? { currentNodeId: run.currentNodeId }
|
|
204
|
+
: {}),
|
|
205
|
+
...(patch.waitingNodeId === null
|
|
206
|
+
? {}
|
|
207
|
+
: patch.waitingNodeId !== undefined
|
|
208
|
+
? { waitingNodeId: patch.waitingNodeId }
|
|
209
|
+
: run.waitingNodeId
|
|
210
|
+
? { waitingNodeId: run.waitingNodeId }
|
|
211
|
+
: {}),
|
|
212
|
+
readyNodeIds: patch.readyNodeIds ? [...patch.readyNodeIds] : [...run.readyNodeIds],
|
|
213
|
+
failureCount: patch.failureCount ?? run.failureCount,
|
|
214
|
+
...(patch.replacedRunId === null
|
|
215
|
+
? {}
|
|
216
|
+
: patch.replacedRunId
|
|
217
|
+
? { replacedRunId: ensureRecordId(patch.replacedRunId, TABLES.PLAN_RUN) }
|
|
218
|
+
: run.replacedRunId
|
|
219
|
+
? { replacedRunId: ensureRecordId(run.replacedRunId, TABLES.PLAN_RUN) }
|
|
220
|
+
: {}),
|
|
221
|
+
...(patch.lastCheckpointId === null
|
|
222
|
+
? {}
|
|
223
|
+
: patch.lastCheckpointId
|
|
224
|
+
? { lastCheckpointId: ensureRecordId(patch.lastCheckpointId, TABLES.PLAN_CHECKPOINT) }
|
|
225
|
+
: run.lastCheckpointId
|
|
226
|
+
? { lastCheckpointId: ensureRecordId(run.lastCheckpointId, TABLES.PLAN_CHECKPOINT) }
|
|
227
|
+
: {}),
|
|
228
|
+
...(patch.startedAt === null
|
|
229
|
+
? {}
|
|
230
|
+
: patch.startedAt !== undefined
|
|
231
|
+
? { startedAt: patch.startedAt }
|
|
232
|
+
: run.startedAt
|
|
233
|
+
? { startedAt: run.startedAt }
|
|
234
|
+
: {}),
|
|
235
|
+
...(patch.completedAt === null
|
|
236
|
+
? {}
|
|
237
|
+
: patch.completedAt !== undefined
|
|
238
|
+
? { completedAt: patch.completedAt }
|
|
239
|
+
: run.completedAt
|
|
240
|
+
? { completedAt: run.completedAt }
|
|
241
|
+
: {}),
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function toNodeRunData(nodeRun: PlanNodeRunRecord, patch: PlanNodeRunUpdate) {
|
|
246
|
+
return {
|
|
247
|
+
runId: ensureRecordId(nodeRun.runId, TABLES.PLAN_RUN),
|
|
248
|
+
planSpecId: ensureRecordId(nodeRun.planSpecId, TABLES.PLAN_SPEC),
|
|
249
|
+
nodeId: nodeRun.nodeId,
|
|
250
|
+
status: patch.status ?? nodeRun.status,
|
|
251
|
+
attemptCount: patch.attemptCount ?? nodeRun.attemptCount,
|
|
252
|
+
retryCount: patch.retryCount ?? nodeRun.retryCount,
|
|
253
|
+
...(patch.resolvedInput === null
|
|
254
|
+
? {}
|
|
255
|
+
: patch.resolvedInput !== undefined
|
|
256
|
+
? { resolvedInput: patch.resolvedInput }
|
|
257
|
+
: nodeRun.resolvedInput
|
|
258
|
+
? { resolvedInput: nodeRun.resolvedInput }
|
|
259
|
+
: {}),
|
|
260
|
+
...(patch.latestStructuredOutput === null
|
|
261
|
+
? {}
|
|
262
|
+
: patch.latestStructuredOutput !== undefined
|
|
263
|
+
? { latestStructuredOutput: patch.latestStructuredOutput }
|
|
264
|
+
: nodeRun.latestStructuredOutput
|
|
265
|
+
? { latestStructuredOutput: nodeRun.latestStructuredOutput }
|
|
266
|
+
: {}),
|
|
267
|
+
...(patch.latestNotes === null
|
|
268
|
+
? {}
|
|
269
|
+
: patch.latestNotes !== undefined
|
|
270
|
+
? { latestNotes: patch.latestNotes }
|
|
271
|
+
: nodeRun.latestNotes
|
|
272
|
+
? { latestNotes: nodeRun.latestNotes }
|
|
273
|
+
: {}),
|
|
274
|
+
...(patch.latestAttemptId === null
|
|
275
|
+
? {}
|
|
276
|
+
: patch.latestAttemptId !== undefined
|
|
277
|
+
? { latestAttemptId: ensureRecordId(patch.latestAttemptId, TABLES.PLAN_NODE_ATTEMPT) }
|
|
278
|
+
: nodeRun.latestAttemptId
|
|
279
|
+
? { latestAttemptId: ensureRecordId(nodeRun.latestAttemptId, TABLES.PLAN_NODE_ATTEMPT) }
|
|
280
|
+
: {}),
|
|
281
|
+
...(patch.blockedReason === null
|
|
282
|
+
? {}
|
|
283
|
+
: patch.blockedReason !== undefined
|
|
284
|
+
? { blockedReason: patch.blockedReason }
|
|
285
|
+
: nodeRun.blockedReason
|
|
286
|
+
? { blockedReason: nodeRun.blockedReason }
|
|
287
|
+
: {}),
|
|
288
|
+
...(patch.failureClass === null
|
|
289
|
+
? {}
|
|
290
|
+
: patch.failureClass !== undefined
|
|
291
|
+
? { failureClass: patch.failureClass }
|
|
292
|
+
: nodeRun.failureClass
|
|
293
|
+
? { failureClass: nodeRun.failureClass }
|
|
294
|
+
: {}),
|
|
295
|
+
...(patch.readyAt === null
|
|
296
|
+
? {}
|
|
297
|
+
: patch.readyAt !== undefined
|
|
298
|
+
? { readyAt: patch.readyAt }
|
|
299
|
+
: nodeRun.readyAt
|
|
300
|
+
? { readyAt: nodeRun.readyAt }
|
|
301
|
+
: {}),
|
|
302
|
+
...(patch.startedAt === null
|
|
303
|
+
? {}
|
|
304
|
+
: patch.startedAt !== undefined
|
|
305
|
+
? { startedAt: patch.startedAt }
|
|
306
|
+
: nodeRun.startedAt
|
|
307
|
+
? { startedAt: nodeRun.startedAt }
|
|
308
|
+
: {}),
|
|
309
|
+
...(patch.completedAt === null
|
|
310
|
+
? {}
|
|
311
|
+
: patch.completedAt !== undefined
|
|
312
|
+
? { completedAt: patch.completedAt }
|
|
313
|
+
: nodeRun.completedAt
|
|
314
|
+
? { completedAt: nodeRun.completedAt }
|
|
315
|
+
: {}),
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function buildToolResult(params: {
|
|
320
|
+
action: ExecutionPlanToolResultData['action']
|
|
321
|
+
plan: SerializableExecutionPlan | null
|
|
322
|
+
message: string
|
|
323
|
+
changedNodeId?: string
|
|
324
|
+
}): ExecutionPlanToolResultData {
|
|
325
|
+
return {
|
|
326
|
+
action: params.action,
|
|
327
|
+
message: params.message,
|
|
328
|
+
...(params.changedNodeId ? { changedNodeId: params.changedNodeId } : {}),
|
|
329
|
+
...(params.plan ? { plan: params.plan } : {}),
|
|
330
|
+
hasPlan: params.plan !== null,
|
|
331
|
+
status: params.plan?.status,
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function deriveApprovalStatus(response: Record<string, unknown>): 'approved' | 'rejected' | 'changes-requested' {
|
|
336
|
+
const approved = response.approved === true
|
|
337
|
+
const requiredEdits = Array.isArray(response.requiredEdits)
|
|
338
|
+
? response.requiredEdits.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
|
|
339
|
+
: []
|
|
340
|
+
|
|
341
|
+
if (approved && requiredEdits.length === 0) return 'approved'
|
|
342
|
+
if (requiredEdits.length > 0) return 'changes-requested'
|
|
343
|
+
return 'rejected'
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
class PlanExecutorService {
|
|
347
|
+
async submitNodeResult(params: {
|
|
348
|
+
workstreamId: RecordIdInput
|
|
349
|
+
runId: string
|
|
350
|
+
nodeId: string
|
|
351
|
+
emittedBy: string
|
|
352
|
+
result: PlanNodeResultSubmission
|
|
353
|
+
}): Promise<ExecutionPlanToolResultData> {
|
|
354
|
+
const run = await planRunService.getRunById(params.runId)
|
|
355
|
+
if (
|
|
356
|
+
recordIdToString(run.workstreamId, TABLES.WORKSTREAM) !== recordIdToString(params.workstreamId, TABLES.WORKSTREAM)
|
|
357
|
+
) {
|
|
358
|
+
throw new Error('Execution node result targets a different workstream.')
|
|
359
|
+
}
|
|
360
|
+
if (run.status === 'completed' || run.status === 'failed' || run.status === 'aborted') {
|
|
361
|
+
throw new Error('Execution run is no longer active.')
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const spec = await planRunService.getPlanSpecById(run.planSpecId)
|
|
365
|
+
const nodeSpecs = await planRunService.listNodeSpecs(spec.id)
|
|
366
|
+
const nodeSpec = nodeSpecs.find((candidate) => candidate.nodeId === params.nodeId)
|
|
367
|
+
if (!nodeSpec) {
|
|
368
|
+
throw new Error(`Execution node "${params.nodeId}" does not exist in this run.`)
|
|
369
|
+
}
|
|
370
|
+
if (isHumanNodeType(nodeSpec.type) || isStructuralNodeType(nodeSpec.type)) {
|
|
371
|
+
throw new Error(
|
|
372
|
+
`Execution node "${nodeSpec.label}" is executor-owned and cannot accept direct result submission.`,
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const nodeRun = await planRunService.getNodeRunByNodeId(run.id, params.nodeId)
|
|
377
|
+
if (nodeRun.status !== 'running') {
|
|
378
|
+
throw new Error(`Execution node "${nodeSpec.label}" is not currently running.`)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const existingArtifacts = await planRunService.listArtifacts(run.id)
|
|
382
|
+
const latestCheckpoint = await planRunService.getLatestCheckpoint(run.id)
|
|
383
|
+
const validation = planValidatorService.validateNodeResult({
|
|
384
|
+
draft: { schemas: spec.schemaRegistry },
|
|
385
|
+
node: {
|
|
386
|
+
id: nodeSpec.nodeId,
|
|
387
|
+
type: nodeSpec.type,
|
|
388
|
+
label: nodeSpec.label,
|
|
389
|
+
owner: nodeSpec.owner,
|
|
390
|
+
objective: nodeSpec.objective,
|
|
391
|
+
instructions: nodeSpec.instructions,
|
|
392
|
+
inputSchemaRef: nodeSpec.inputSchemaRef,
|
|
393
|
+
outputSchemaRef: nodeSpec.outputSchemaRef,
|
|
394
|
+
deliverables: [...nodeSpec.deliverables],
|
|
395
|
+
successCriteria: [...nodeSpec.successCriteria],
|
|
396
|
+
completionChecks: [...nodeSpec.completionChecks],
|
|
397
|
+
retryPolicy: { ...nodeSpec.retryPolicy, retryOn: [...nodeSpec.retryPolicy.retryOn] },
|
|
398
|
+
failurePolicy: [...nodeSpec.failurePolicy],
|
|
399
|
+
timeoutMs: nodeSpec.timeoutMs,
|
|
400
|
+
toolPolicy: { allow: [...nodeSpec.toolPolicy.allow], deny: [...nodeSpec.toolPolicy.deny] },
|
|
401
|
+
contextPolicy: {
|
|
402
|
+
retrievalScopes: [...nodeSpec.contextPolicy.retrievalScopes],
|
|
403
|
+
attachmentPolicy: nodeSpec.contextPolicy.attachmentPolicy,
|
|
404
|
+
webPolicy: nodeSpec.contextPolicy.webPolicy,
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
result: params.result,
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
await databaseService.withTransaction(async (tx) => {
|
|
411
|
+
const attempt = await this.createAttempt({
|
|
412
|
+
tx,
|
|
413
|
+
run,
|
|
414
|
+
nodeRun,
|
|
415
|
+
emittedBy: params.emittedBy,
|
|
416
|
+
result: params.result,
|
|
417
|
+
status: validation.blocking.length > 0 ? 'failed' : 'completed',
|
|
418
|
+
failureClass: validation.failureClass,
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
const issues = await this.persistValidationIssues({
|
|
422
|
+
tx,
|
|
423
|
+
run,
|
|
424
|
+
spec,
|
|
425
|
+
attemptId: attempt.id,
|
|
426
|
+
nodeId: params.nodeId,
|
|
427
|
+
issues: [...validation.blocking, ...validation.warnings],
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
const finalizedAttempt = PlanNodeAttemptSchema.parse(
|
|
431
|
+
await tx
|
|
432
|
+
.update(ensureRecordId(attempt.id, TABLES.PLAN_NODE_ATTEMPT))
|
|
433
|
+
.content({
|
|
434
|
+
runId: ensureRecordId(attempt.runId, TABLES.PLAN_RUN),
|
|
435
|
+
nodeRunId: ensureRecordId(attempt.nodeRunId, TABLES.PLAN_NODE_RUN),
|
|
436
|
+
nodeId: attempt.nodeId,
|
|
437
|
+
emittedBy: attempt.emittedBy,
|
|
438
|
+
status: attempt.status,
|
|
439
|
+
...(attempt.structuredOutput ? { structuredOutput: attempt.structuredOutput } : {}),
|
|
440
|
+
...(attempt.notes ? { notes: attempt.notes } : {}),
|
|
441
|
+
validationIssueIds: issues.map((issue) => ensureRecordId(issue.id, TABLES.PLAN_VALIDATION_ISSUE)),
|
|
442
|
+
...(attempt.failureClass ? { failureClass: attempt.failureClass } : {}),
|
|
443
|
+
})
|
|
444
|
+
.output('after'),
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
const persistedArtifacts = await planArtifactService.persistArtifacts({
|
|
448
|
+
tx,
|
|
449
|
+
runId: run.id,
|
|
450
|
+
attemptId: finalizedAttempt.id,
|
|
451
|
+
nodeId: params.nodeId,
|
|
452
|
+
artifacts: params.result.artifacts,
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
let nextNodeRun = PlanNodeRunSchema.parse(
|
|
456
|
+
await tx
|
|
457
|
+
.update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
|
|
458
|
+
.content(
|
|
459
|
+
toNodeRunData(nodeRun, {
|
|
460
|
+
attemptCount: nodeRun.attemptCount + 1,
|
|
461
|
+
latestAttemptId: finalizedAttempt.id,
|
|
462
|
+
latestStructuredOutput: params.result.structuredOutput ?? null,
|
|
463
|
+
latestNotes: params.result.notes ?? null,
|
|
464
|
+
}),
|
|
465
|
+
)
|
|
466
|
+
.output('after'),
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
const nodeRuns = await planRunService.listNodeRuns(run.id)
|
|
470
|
+
const withUpdatedNodeRuns = nodeRuns.map((candidate) =>
|
|
471
|
+
candidate.nodeId === nextNodeRun.nodeId ? nextNodeRun : candidate,
|
|
472
|
+
)
|
|
473
|
+
const nextArtifacts = [...existingArtifacts, ...persistedArtifacts]
|
|
474
|
+
|
|
475
|
+
if (validation.blocking.length > 0) {
|
|
476
|
+
const shouldRetry =
|
|
477
|
+
validation.failureClass &&
|
|
478
|
+
nodeSpec.retryPolicy.maxAttempts > nextNodeRun.retryCount &&
|
|
479
|
+
(nodeSpec.retryPolicy.retryOn.length === 0 || nodeSpec.retryPolicy.retryOn.includes(validation.failureClass))
|
|
480
|
+
|
|
481
|
+
if (shouldRetry) {
|
|
482
|
+
nextNodeRun = PlanNodeRunSchema.parse(
|
|
483
|
+
await tx
|
|
484
|
+
.update(ensureRecordId(nextNodeRun.id, TABLES.PLAN_NODE_RUN))
|
|
485
|
+
.content(
|
|
486
|
+
toNodeRunData(nextNodeRun, {
|
|
487
|
+
status: 'ready',
|
|
488
|
+
retryCount: nextNodeRun.retryCount + 1,
|
|
489
|
+
failureClass: validation.failureClass,
|
|
490
|
+
blockedReason: validation.blocking[0]?.message ?? null,
|
|
491
|
+
readyAt: new Date(),
|
|
492
|
+
startedAt: null,
|
|
493
|
+
completedAt: null,
|
|
494
|
+
}),
|
|
495
|
+
)
|
|
496
|
+
.output('after'),
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
await this.emitEvent({
|
|
500
|
+
tx,
|
|
501
|
+
run,
|
|
502
|
+
spec,
|
|
503
|
+
nodeId: nextNodeRun.nodeId,
|
|
504
|
+
attemptId: finalizedAttempt.id,
|
|
505
|
+
eventType: 'validation-reported',
|
|
506
|
+
message: `Validation failed for node "${nodeSpec.label}", scheduling retry.`,
|
|
507
|
+
detail: { issues: validation.blocking.map((issue) => issue.code) },
|
|
508
|
+
emittedBy: params.emittedBy,
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
const synced = await this.syncRunGraph({
|
|
512
|
+
tx,
|
|
513
|
+
run,
|
|
514
|
+
spec,
|
|
515
|
+
nodeSpecs,
|
|
516
|
+
nodeRuns: withUpdatedNodeRuns.map((candidate) =>
|
|
517
|
+
candidate.nodeId === nextNodeRun.nodeId ? nextNodeRun : candidate,
|
|
518
|
+
),
|
|
519
|
+
artifacts: nextArtifacts,
|
|
520
|
+
emittedBy: params.emittedBy,
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
const checkpoint = await this.saveCheckpoint({
|
|
524
|
+
tx,
|
|
525
|
+
run: synced.run,
|
|
526
|
+
nodeRuns: synced.nodeRuns,
|
|
527
|
+
artifacts: synced.artifacts,
|
|
528
|
+
sequence: (latestCheckpoint?.sequence ?? 0) + 1,
|
|
529
|
+
reason: 'node-result-retry',
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
await this.attachCheckpoint(tx, synced.run, checkpoint)
|
|
533
|
+
return
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const failureAction = this.resolveFailureAction(nodeSpec, validation.failureClass)
|
|
537
|
+
if (failureAction === 'human-review') {
|
|
538
|
+
nextNodeRun = PlanNodeRunSchema.parse(
|
|
539
|
+
await tx
|
|
540
|
+
.update(ensureRecordId(nextNodeRun.id, TABLES.PLAN_NODE_RUN))
|
|
541
|
+
.content(
|
|
542
|
+
toNodeRunData(nextNodeRun, {
|
|
543
|
+
status: 'awaiting-human',
|
|
544
|
+
retryCount: nextNodeRun.retryCount + 1,
|
|
545
|
+
failureClass: validation.failureClass,
|
|
546
|
+
blockedReason: validation.blocking[0]?.message ?? null,
|
|
547
|
+
startedAt: nextNodeRun.startedAt ?? new Date(),
|
|
548
|
+
}),
|
|
549
|
+
)
|
|
550
|
+
.output('after'),
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
const approval = await planApprovalService.createPendingApproval({
|
|
554
|
+
tx,
|
|
555
|
+
runId: run.id,
|
|
556
|
+
nodeRunId: nextNodeRun.id,
|
|
557
|
+
nodeId: nextNodeRun.nodeId,
|
|
558
|
+
requestedBy: params.emittedBy,
|
|
559
|
+
presented: {
|
|
560
|
+
nodeId: nodeSpec.nodeId,
|
|
561
|
+
label: nodeSpec.label,
|
|
562
|
+
objective: nodeSpec.objective,
|
|
563
|
+
instructions: nodeSpec.instructions,
|
|
564
|
+
validationIssues: validation.blocking,
|
|
565
|
+
},
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
const failedRun = await this.replaceRun(tx, run, {
|
|
569
|
+
status: 'awaiting-human',
|
|
570
|
+
currentNodeId: nextNodeRun.nodeId,
|
|
571
|
+
waitingNodeId: nextNodeRun.nodeId,
|
|
572
|
+
readyNodeIds: [],
|
|
573
|
+
failureCount: run.failureCount + 1,
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
await this.emitEvent({
|
|
577
|
+
tx,
|
|
578
|
+
run: failedRun,
|
|
579
|
+
spec,
|
|
580
|
+
nodeId: nextNodeRun.nodeId,
|
|
581
|
+
attemptId: finalizedAttempt.id,
|
|
582
|
+
approvalId: approval.id,
|
|
583
|
+
eventType: 'approval-requested',
|
|
584
|
+
fromStatus: run.status,
|
|
585
|
+
toStatus: failedRun.status,
|
|
586
|
+
message: `Node "${nodeSpec.label}" requires human review before continuing.`,
|
|
587
|
+
detail: { issues: validation.blocking.map((issue) => issue.code) },
|
|
588
|
+
emittedBy: params.emittedBy,
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
const checkpoint = await this.saveCheckpoint({
|
|
592
|
+
tx,
|
|
593
|
+
run: failedRun,
|
|
594
|
+
nodeRuns: withUpdatedNodeRuns.map((candidate) =>
|
|
595
|
+
candidate.nodeId === nextNodeRun.nodeId ? nextNodeRun : candidate,
|
|
596
|
+
),
|
|
597
|
+
artifacts: nextArtifacts,
|
|
598
|
+
sequence: (latestCheckpoint?.sequence ?? 0) + 1,
|
|
599
|
+
reason: 'node-result-human-review',
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
await this.attachCheckpoint(tx, failedRun, checkpoint)
|
|
603
|
+
return
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (failureAction === 'replan') {
|
|
607
|
+
nextNodeRun = PlanNodeRunSchema.parse(
|
|
608
|
+
await tx
|
|
609
|
+
.update(ensureRecordId(nextNodeRun.id, TABLES.PLAN_NODE_RUN))
|
|
610
|
+
.content(
|
|
611
|
+
toNodeRunData(nextNodeRun, {
|
|
612
|
+
status: 'blocked',
|
|
613
|
+
retryCount: nextNodeRun.retryCount + 1,
|
|
614
|
+
failureClass: validation.failureClass,
|
|
615
|
+
blockedReason: validation.blocking[0]?.message ?? null,
|
|
616
|
+
}),
|
|
617
|
+
)
|
|
618
|
+
.output('after'),
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
const blockedRun = await this.replaceRun(tx, run, {
|
|
622
|
+
status: 'blocked',
|
|
623
|
+
currentNodeId: nextNodeRun.nodeId,
|
|
624
|
+
waitingNodeId: null,
|
|
625
|
+
readyNodeIds: [],
|
|
626
|
+
failureCount: run.failureCount + 1,
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
await this.emitEvent({
|
|
630
|
+
tx,
|
|
631
|
+
run: blockedRun,
|
|
632
|
+
spec,
|
|
633
|
+
nodeId: nextNodeRun.nodeId,
|
|
634
|
+
attemptId: finalizedAttempt.id,
|
|
635
|
+
eventType: 'node-blocked',
|
|
636
|
+
fromStatus: nodeRun.status,
|
|
637
|
+
toStatus: nextNodeRun.status,
|
|
638
|
+
message: `Node "${nodeSpec.label}" failed validation and requires replanning.`,
|
|
639
|
+
detail: { failureClass: validation.failureClass, issues: validation.blocking.map((issue) => issue.code) },
|
|
640
|
+
emittedBy: params.emittedBy,
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
const checkpoint = await this.saveCheckpoint({
|
|
644
|
+
tx,
|
|
645
|
+
run: blockedRun,
|
|
646
|
+
nodeRuns: withUpdatedNodeRuns.map((candidate) =>
|
|
647
|
+
candidate.nodeId === nextNodeRun.nodeId ? nextNodeRun : candidate,
|
|
648
|
+
),
|
|
649
|
+
artifacts: nextArtifacts,
|
|
650
|
+
sequence: (latestCheckpoint?.sequence ?? 0) + 1,
|
|
651
|
+
reason: 'node-result-replan',
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
await this.attachCheckpoint(tx, blockedRun, checkpoint)
|
|
655
|
+
return
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
nextNodeRun = PlanNodeRunSchema.parse(
|
|
659
|
+
await tx
|
|
660
|
+
.update(ensureRecordId(nextNodeRun.id, TABLES.PLAN_NODE_RUN))
|
|
661
|
+
.content(
|
|
662
|
+
toNodeRunData(nextNodeRun, {
|
|
663
|
+
status: 'failed',
|
|
664
|
+
retryCount: nextNodeRun.retryCount + 1,
|
|
665
|
+
failureClass: validation.failureClass,
|
|
666
|
+
blockedReason: validation.blocking[0]?.message ?? null,
|
|
667
|
+
completedAt: new Date(),
|
|
668
|
+
}),
|
|
669
|
+
)
|
|
670
|
+
.output('after'),
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
const failedRun = await this.replaceRun(tx, run, {
|
|
674
|
+
status: 'failed',
|
|
675
|
+
currentNodeId: null,
|
|
676
|
+
waitingNodeId: null,
|
|
677
|
+
readyNodeIds: [],
|
|
678
|
+
failureCount: run.failureCount + 1,
|
|
679
|
+
completedAt: new Date(),
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
await this.emitEvent({
|
|
683
|
+
tx,
|
|
684
|
+
run: failedRun,
|
|
685
|
+
spec,
|
|
686
|
+
nodeId: nextNodeRun.nodeId,
|
|
687
|
+
attemptId: finalizedAttempt.id,
|
|
688
|
+
eventType: 'node-failed',
|
|
689
|
+
fromStatus: nodeRun.status,
|
|
690
|
+
toStatus: nextNodeRun.status,
|
|
691
|
+
message: `Node "${nodeSpec.label}" failed validation and the run has been aborted.`,
|
|
692
|
+
detail: { failureClass: validation.failureClass, issues: validation.blocking.map((issue) => issue.code) },
|
|
693
|
+
emittedBy: params.emittedBy,
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
const checkpoint = await this.saveCheckpoint({
|
|
697
|
+
tx,
|
|
698
|
+
run: failedRun,
|
|
699
|
+
nodeRuns: withUpdatedNodeRuns.map((candidate) =>
|
|
700
|
+
candidate.nodeId === nextNodeRun.nodeId ? nextNodeRun : candidate,
|
|
701
|
+
),
|
|
702
|
+
artifacts: nextArtifacts,
|
|
703
|
+
sequence: (latestCheckpoint?.sequence ?? 0) + 1,
|
|
704
|
+
reason: 'node-result-failed',
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
await this.attachCheckpoint(tx, failedRun, checkpoint)
|
|
708
|
+
return
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
nextNodeRun = PlanNodeRunSchema.parse(
|
|
712
|
+
await tx
|
|
713
|
+
.update(ensureRecordId(nextNodeRun.id, TABLES.PLAN_NODE_RUN))
|
|
714
|
+
.content(
|
|
715
|
+
toNodeRunData(nextNodeRun, {
|
|
716
|
+
status: validation.warnings.length > 0 ? 'partial' : 'completed',
|
|
717
|
+
latestAttemptId: finalizedAttempt.id,
|
|
718
|
+
latestStructuredOutput: params.result.structuredOutput ?? null,
|
|
719
|
+
latestNotes: params.result.notes ?? null,
|
|
720
|
+
blockedReason: null,
|
|
721
|
+
failureClass: null,
|
|
722
|
+
completedAt: new Date(),
|
|
723
|
+
}),
|
|
724
|
+
)
|
|
725
|
+
.output('after'),
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
await this.emitEvent({
|
|
729
|
+
tx,
|
|
730
|
+
run,
|
|
731
|
+
spec,
|
|
732
|
+
nodeId: nextNodeRun.nodeId,
|
|
733
|
+
attemptId: finalizedAttempt.id,
|
|
734
|
+
eventType: validation.warnings.length > 0 ? 'node-partial' : 'node-completed',
|
|
735
|
+
fromStatus: nodeRun.status,
|
|
736
|
+
toStatus: nextNodeRun.status,
|
|
737
|
+
message:
|
|
738
|
+
validation.warnings.length > 0
|
|
739
|
+
? `Node "${nodeSpec.label}" completed with warnings.`
|
|
740
|
+
: `Node "${nodeSpec.label}" completed successfully.`,
|
|
741
|
+
detail: { warningCount: validation.warnings.length },
|
|
742
|
+
emittedBy: params.emittedBy,
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
const synced = await this.syncRunGraph({
|
|
746
|
+
tx,
|
|
747
|
+
run,
|
|
748
|
+
spec,
|
|
749
|
+
nodeSpecs,
|
|
750
|
+
nodeRuns: withUpdatedNodeRuns.map((candidate) =>
|
|
751
|
+
candidate.nodeId === nextNodeRun.nodeId ? nextNodeRun : candidate,
|
|
752
|
+
),
|
|
753
|
+
artifacts: nextArtifacts,
|
|
754
|
+
emittedBy: params.emittedBy,
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
const checkpoint = await this.saveCheckpoint({
|
|
758
|
+
tx,
|
|
759
|
+
run: synced.run,
|
|
760
|
+
nodeRuns: synced.nodeRuns,
|
|
761
|
+
artifacts: synced.artifacts,
|
|
762
|
+
sequence: (latestCheckpoint?.sequence ?? 0) + 1,
|
|
763
|
+
reason: 'node-result-complete',
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
await this.attachCheckpoint(tx, synced.run, checkpoint)
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
const snapshot = await planRunService.toSerializablePlan(await planRunService.getRunById(run.id), {
|
|
770
|
+
includeEvents: true,
|
|
771
|
+
includeArtifacts: true,
|
|
772
|
+
includeApprovals: true,
|
|
773
|
+
includeCheckpoints: true,
|
|
774
|
+
includeValidationIssues: true,
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
return buildToolResult({
|
|
778
|
+
action: 'node-result-submitted',
|
|
779
|
+
plan: snapshot,
|
|
780
|
+
message: `Submitted result for node "${nodeSpec.label}".`,
|
|
781
|
+
changedNodeId: params.nodeId,
|
|
782
|
+
})
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
async submitHumanNodeResponse(params: {
|
|
786
|
+
workstreamId: RecordIdInput
|
|
787
|
+
approvalId?: string
|
|
788
|
+
respondedBy: string
|
|
789
|
+
response: Record<string, unknown>
|
|
790
|
+
approvalMessageId?: string
|
|
791
|
+
}): Promise<SerializableExecutionPlan | null> {
|
|
792
|
+
const run = await planRunService.getActiveRunRecord(params.workstreamId)
|
|
793
|
+
if (!run || run.status !== 'awaiting-human' || !run.waitingNodeId) {
|
|
794
|
+
return null
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const spec = await planRunService.getPlanSpecById(run.planSpecId)
|
|
798
|
+
const nodeSpecs = await planRunService.listNodeSpecs(spec.id)
|
|
799
|
+
const nodeSpec = nodeSpecs.find((candidate) => candidate.nodeId === run.waitingNodeId)
|
|
800
|
+
if (!nodeSpec) {
|
|
801
|
+
throw new Error(`Waiting node "${run.waitingNodeId}" does not exist.`)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const nodeRun = await planRunService.getNodeRunByNodeId(run.id, run.waitingNodeId)
|
|
805
|
+
const approval =
|
|
806
|
+
(params.approvalId ? await planApprovalService.getApprovalById(params.approvalId) : null) ??
|
|
807
|
+
(await planApprovalService.getPendingApprovalForNodeRun(nodeRun.id))
|
|
808
|
+
if (!approval) {
|
|
809
|
+
throw new Error(`No pending approval exists for node "${nodeSpec.label}".`)
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const existingArtifacts = await planRunService.listArtifacts(run.id)
|
|
813
|
+
const latestCheckpoint = await planRunService.getLatestCheckpoint(run.id)
|
|
814
|
+
const validation = planValidatorService.validateNodeResult({
|
|
815
|
+
draft: { schemas: spec.schemaRegistry },
|
|
816
|
+
node: {
|
|
817
|
+
id: nodeSpec.nodeId,
|
|
818
|
+
type: nodeSpec.type,
|
|
819
|
+
label: nodeSpec.label,
|
|
820
|
+
owner: nodeSpec.owner,
|
|
821
|
+
objective: nodeSpec.objective,
|
|
822
|
+
instructions: nodeSpec.instructions,
|
|
823
|
+
inputSchemaRef: nodeSpec.inputSchemaRef,
|
|
824
|
+
outputSchemaRef: nodeSpec.outputSchemaRef,
|
|
825
|
+
deliverables: [...nodeSpec.deliverables],
|
|
826
|
+
successCriteria: [...nodeSpec.successCriteria],
|
|
827
|
+
completionChecks: [...nodeSpec.completionChecks],
|
|
828
|
+
retryPolicy: { ...nodeSpec.retryPolicy, retryOn: [...nodeSpec.retryPolicy.retryOn] },
|
|
829
|
+
failurePolicy: [...nodeSpec.failurePolicy],
|
|
830
|
+
timeoutMs: nodeSpec.timeoutMs,
|
|
831
|
+
toolPolicy: { allow: [...nodeSpec.toolPolicy.allow], deny: [...nodeSpec.toolPolicy.deny] },
|
|
832
|
+
contextPolicy: {
|
|
833
|
+
retrievalScopes: [...nodeSpec.contextPolicy.retrievalScopes],
|
|
834
|
+
attachmentPolicy: nodeSpec.contextPolicy.attachmentPolicy,
|
|
835
|
+
webPolicy: nodeSpec.contextPolicy.webPolicy,
|
|
836
|
+
},
|
|
837
|
+
},
|
|
838
|
+
result: {
|
|
839
|
+
structuredOutput: params.response,
|
|
840
|
+
artifacts: [],
|
|
841
|
+
notes: typeof params.response.comments === 'string' ? params.response.comments : undefined,
|
|
842
|
+
},
|
|
843
|
+
})
|
|
844
|
+
|
|
845
|
+
await databaseService.withTransaction(async (tx) => {
|
|
846
|
+
const approvalStatus = deriveApprovalStatus(params.response)
|
|
847
|
+
await planApprovalService.updateApprovalResponse({
|
|
848
|
+
tx,
|
|
849
|
+
approval,
|
|
850
|
+
status: approvalStatus,
|
|
851
|
+
response: params.response,
|
|
852
|
+
respondedBy: params.respondedBy,
|
|
853
|
+
approvalMessageId: params.approvalMessageId,
|
|
854
|
+
comments: typeof params.response.comments === 'string' ? params.response.comments : undefined,
|
|
855
|
+
requiredEdits: Array.isArray(params.response.requiredEdits)
|
|
856
|
+
? params.response.requiredEdits.filter((entry): entry is string => typeof entry === 'string')
|
|
857
|
+
: undefined,
|
|
858
|
+
})
|
|
859
|
+
|
|
860
|
+
const attempt = await this.createAttempt({
|
|
861
|
+
tx,
|
|
862
|
+
run,
|
|
863
|
+
nodeRun,
|
|
864
|
+
emittedBy: params.respondedBy,
|
|
865
|
+
result: {
|
|
866
|
+
structuredOutput: params.response,
|
|
867
|
+
artifacts: [],
|
|
868
|
+
notes: typeof params.response.comments === 'string' ? params.response.comments : undefined,
|
|
869
|
+
},
|
|
870
|
+
status: validation.blocking.length > 0 ? 'failed' : 'completed',
|
|
871
|
+
failureClass: validation.failureClass,
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
const issues = await this.persistValidationIssues({
|
|
875
|
+
tx,
|
|
876
|
+
run,
|
|
877
|
+
spec,
|
|
878
|
+
attemptId: attempt.id,
|
|
879
|
+
nodeId: nodeRun.nodeId,
|
|
880
|
+
issues: [...validation.blocking, ...validation.warnings],
|
|
881
|
+
})
|
|
882
|
+
|
|
883
|
+
await tx
|
|
884
|
+
.update(ensureRecordId(attempt.id, TABLES.PLAN_NODE_ATTEMPT))
|
|
885
|
+
.content({
|
|
886
|
+
runId: ensureRecordId(attempt.runId, TABLES.PLAN_RUN),
|
|
887
|
+
nodeRunId: ensureRecordId(attempt.nodeRunId, TABLES.PLAN_NODE_RUN),
|
|
888
|
+
nodeId: attempt.nodeId,
|
|
889
|
+
emittedBy: attempt.emittedBy,
|
|
890
|
+
status: attempt.status,
|
|
891
|
+
structuredOutput: params.response,
|
|
892
|
+
validationIssueIds: issues.map((issue) => ensureRecordId(issue.id, TABLES.PLAN_VALIDATION_ISSUE)),
|
|
893
|
+
...(attempt.notes ? { notes: attempt.notes } : {}),
|
|
894
|
+
...(attempt.failureClass ? { failureClass: attempt.failureClass } : {}),
|
|
895
|
+
})
|
|
896
|
+
.output('after')
|
|
897
|
+
|
|
898
|
+
const nextNodeRun =
|
|
899
|
+
validation.blocking.length > 0
|
|
900
|
+
? PlanNodeRunSchema.parse(
|
|
901
|
+
await tx
|
|
902
|
+
.update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
|
|
903
|
+
.content(
|
|
904
|
+
toNodeRunData(nodeRun, {
|
|
905
|
+
status: 'blocked',
|
|
906
|
+
attemptCount: nodeRun.attemptCount + 1,
|
|
907
|
+
latestAttemptId: attempt.id,
|
|
908
|
+
latestStructuredOutput: params.response,
|
|
909
|
+
latestNotes: typeof params.response.comments === 'string' ? params.response.comments : null,
|
|
910
|
+
blockedReason: validation.blocking[0]?.message ?? null,
|
|
911
|
+
failureClass: validation.failureClass,
|
|
912
|
+
}),
|
|
913
|
+
)
|
|
914
|
+
.output('after'),
|
|
915
|
+
)
|
|
916
|
+
: PlanNodeRunSchema.parse(
|
|
917
|
+
await tx
|
|
918
|
+
.update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
|
|
919
|
+
.content(
|
|
920
|
+
toNodeRunData(nodeRun, {
|
|
921
|
+
status: 'completed',
|
|
922
|
+
attemptCount: nodeRun.attemptCount + 1,
|
|
923
|
+
latestAttemptId: attempt.id,
|
|
924
|
+
latestStructuredOutput: params.response,
|
|
925
|
+
latestNotes: typeof params.response.comments === 'string' ? params.response.comments : null,
|
|
926
|
+
blockedReason: null,
|
|
927
|
+
failureClass: null,
|
|
928
|
+
completedAt: new Date(),
|
|
929
|
+
}),
|
|
930
|
+
)
|
|
931
|
+
.output('after'),
|
|
932
|
+
)
|
|
933
|
+
|
|
934
|
+
const nodeRuns = (await planRunService.listNodeRuns(run.id)).map((candidate) =>
|
|
935
|
+
candidate.nodeId === nextNodeRun.nodeId ? nextNodeRun : candidate,
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
if (validation.blocking.length > 0) {
|
|
939
|
+
const blockedRun = await this.replaceRun(tx, run, {
|
|
940
|
+
status: 'blocked',
|
|
941
|
+
currentNodeId: nextNodeRun.nodeId,
|
|
942
|
+
waitingNodeId: null,
|
|
943
|
+
readyNodeIds: [],
|
|
944
|
+
failureCount: run.failureCount + 1,
|
|
945
|
+
})
|
|
946
|
+
|
|
947
|
+
await this.emitEvent({
|
|
948
|
+
tx,
|
|
949
|
+
run: blockedRun,
|
|
950
|
+
spec,
|
|
951
|
+
nodeId: nextNodeRun.nodeId,
|
|
952
|
+
attemptId: attempt.id,
|
|
953
|
+
approvalId: approval.id,
|
|
954
|
+
eventType: 'approval-resolved',
|
|
955
|
+
fromStatus: run.status,
|
|
956
|
+
toStatus: blockedRun.status,
|
|
957
|
+
message: `Human response for node "${nodeSpec.label}" blocked execution.`,
|
|
958
|
+
detail: { issues: validation.blocking.map((issue) => issue.code) },
|
|
959
|
+
emittedBy: params.respondedBy,
|
|
960
|
+
})
|
|
961
|
+
|
|
962
|
+
const checkpoint = await this.saveCheckpoint({
|
|
963
|
+
tx,
|
|
964
|
+
run: blockedRun,
|
|
965
|
+
nodeRuns,
|
|
966
|
+
artifacts: existingArtifacts,
|
|
967
|
+
sequence: (latestCheckpoint?.sequence ?? 0) + 1,
|
|
968
|
+
reason: 'human-response-blocked',
|
|
969
|
+
})
|
|
970
|
+
await this.attachCheckpoint(tx, blockedRun, checkpoint)
|
|
971
|
+
return
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const synced = await this.syncRunGraph({
|
|
975
|
+
tx,
|
|
976
|
+
run,
|
|
977
|
+
spec,
|
|
978
|
+
nodeSpecs,
|
|
979
|
+
nodeRuns,
|
|
980
|
+
artifacts: existingArtifacts,
|
|
981
|
+
emittedBy: params.respondedBy,
|
|
982
|
+
})
|
|
983
|
+
|
|
984
|
+
await this.emitEvent({
|
|
985
|
+
tx,
|
|
986
|
+
run: synced.run,
|
|
987
|
+
spec,
|
|
988
|
+
nodeId: nextNodeRun.nodeId,
|
|
989
|
+
attemptId: attempt.id,
|
|
990
|
+
approvalId: approval.id,
|
|
991
|
+
eventType: 'approval-resolved',
|
|
992
|
+
fromStatus: run.status,
|
|
993
|
+
toStatus: synced.run.status,
|
|
994
|
+
message: `Human response for node "${nodeSpec.label}" accepted.`,
|
|
995
|
+
detail: { approvalStatus: approvalStatus },
|
|
996
|
+
emittedBy: params.respondedBy,
|
|
997
|
+
})
|
|
998
|
+
|
|
999
|
+
const checkpoint = await this.saveCheckpoint({
|
|
1000
|
+
tx,
|
|
1001
|
+
run: synced.run,
|
|
1002
|
+
nodeRuns: synced.nodeRuns,
|
|
1003
|
+
artifacts: synced.artifacts,
|
|
1004
|
+
sequence: (latestCheckpoint?.sequence ?? 0) + 1,
|
|
1005
|
+
reason: 'human-response-complete',
|
|
1006
|
+
})
|
|
1007
|
+
await this.attachCheckpoint(tx, synced.run, checkpoint)
|
|
1008
|
+
})
|
|
1009
|
+
|
|
1010
|
+
return await planRunService.toSerializablePlan(await planRunService.getRunById(run.id), {
|
|
1011
|
+
includeEvents: true,
|
|
1012
|
+
includeArtifacts: true,
|
|
1013
|
+
includeApprovals: true,
|
|
1014
|
+
includeCheckpoints: true,
|
|
1015
|
+
includeValidationIssues: true,
|
|
1016
|
+
})
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
async resumeRun(params: {
|
|
1020
|
+
workstreamId: RecordIdInput
|
|
1021
|
+
runId: string
|
|
1022
|
+
emittedBy: string
|
|
1023
|
+
}): Promise<ExecutionPlanToolResultData> {
|
|
1024
|
+
const run = await planRunService.getRunById(params.runId)
|
|
1025
|
+
if (
|
|
1026
|
+
recordIdToString(run.workstreamId, TABLES.WORKSTREAM) !== recordIdToString(params.workstreamId, TABLES.WORKSTREAM)
|
|
1027
|
+
) {
|
|
1028
|
+
throw new Error('Execution run belongs to a different workstream.')
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
const spec = await planRunService.getPlanSpecById(run.planSpecId)
|
|
1032
|
+
const nodeSpecs = await planRunService.listNodeSpecs(spec.id)
|
|
1033
|
+
const nodeRuns = await planRunService.listNodeRuns(run.id)
|
|
1034
|
+
const artifacts = await planRunService.listArtifacts(run.id)
|
|
1035
|
+
const latestCheckpoint = await planRunService.getLatestCheckpoint(run.id)
|
|
1036
|
+
|
|
1037
|
+
await databaseService.withTransaction(async (tx) => {
|
|
1038
|
+
let currentNodeRuns = [...nodeRuns]
|
|
1039
|
+
for (const nodeRun of currentNodeRuns.filter((candidate) => candidate.status === 'running')) {
|
|
1040
|
+
const resetNodeRun = PlanNodeRunSchema.parse(
|
|
1041
|
+
await tx
|
|
1042
|
+
.update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
|
|
1043
|
+
.content(
|
|
1044
|
+
toNodeRunData(nodeRun, {
|
|
1045
|
+
status: 'ready',
|
|
1046
|
+
readyAt: new Date(),
|
|
1047
|
+
startedAt: nodeRun.startedAt ?? new Date(),
|
|
1048
|
+
}),
|
|
1049
|
+
)
|
|
1050
|
+
.output('after'),
|
|
1051
|
+
)
|
|
1052
|
+
currentNodeRuns = currentNodeRuns.map((candidate) =>
|
|
1053
|
+
candidate.nodeId === resetNodeRun.nodeId ? resetNodeRun : candidate,
|
|
1054
|
+
)
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const resetRun = await this.replaceRun(tx, run, {
|
|
1058
|
+
status: run.status === 'awaiting-human' ? 'awaiting-human' : 'running',
|
|
1059
|
+
currentNodeId: run.status === 'awaiting-human' ? (run.currentNodeId ?? null) : null,
|
|
1060
|
+
waitingNodeId: run.status === 'awaiting-human' ? (run.waitingNodeId ?? null) : null,
|
|
1061
|
+
readyNodeIds: currentNodeRuns
|
|
1062
|
+
.filter((candidate) => candidate.status === 'ready')
|
|
1063
|
+
.map((candidate) => candidate.nodeId),
|
|
1064
|
+
})
|
|
1065
|
+
|
|
1066
|
+
await this.emitEvent({
|
|
1067
|
+
tx,
|
|
1068
|
+
run: resetRun,
|
|
1069
|
+
spec,
|
|
1070
|
+
eventType: 'run-resumed',
|
|
1071
|
+
fromStatus: run.status,
|
|
1072
|
+
toStatus: resetRun.status,
|
|
1073
|
+
message: `Run "${spec.title}" resumed from the latest checkpoint.`,
|
|
1074
|
+
detail: latestCheckpoint ? { checkpointId: recordIdToString(latestCheckpoint.id, TABLES.PLAN_CHECKPOINT) } : {},
|
|
1075
|
+
emittedBy: params.emittedBy,
|
|
1076
|
+
})
|
|
1077
|
+
|
|
1078
|
+
const synced =
|
|
1079
|
+
resetRun.status === 'awaiting-human'
|
|
1080
|
+
? { run: resetRun, nodeRuns: currentNodeRuns, artifacts }
|
|
1081
|
+
: await this.syncRunGraph({
|
|
1082
|
+
tx,
|
|
1083
|
+
run: resetRun,
|
|
1084
|
+
spec,
|
|
1085
|
+
nodeSpecs,
|
|
1086
|
+
nodeRuns: currentNodeRuns,
|
|
1087
|
+
artifacts,
|
|
1088
|
+
emittedBy: params.emittedBy,
|
|
1089
|
+
})
|
|
1090
|
+
|
|
1091
|
+
const checkpoint = await this.saveCheckpoint({
|
|
1092
|
+
tx,
|
|
1093
|
+
run: synced.run,
|
|
1094
|
+
nodeRuns: synced.nodeRuns,
|
|
1095
|
+
artifacts: synced.artifacts,
|
|
1096
|
+
sequence: (latestCheckpoint?.sequence ?? 0) + 1,
|
|
1097
|
+
reason: 'run-resumed',
|
|
1098
|
+
})
|
|
1099
|
+
await this.attachCheckpoint(tx, synced.run, checkpoint)
|
|
1100
|
+
})
|
|
1101
|
+
|
|
1102
|
+
const snapshot = await planRunService.toSerializablePlan(await planRunService.getRunById(run.id), {
|
|
1103
|
+
includeEvents: true,
|
|
1104
|
+
includeArtifacts: true,
|
|
1105
|
+
includeApprovals: true,
|
|
1106
|
+
includeCheckpoints: true,
|
|
1107
|
+
includeValidationIssues: true,
|
|
1108
|
+
})
|
|
1109
|
+
|
|
1110
|
+
return buildToolResult({
|
|
1111
|
+
action: 'run-resumed',
|
|
1112
|
+
plan: snapshot,
|
|
1113
|
+
message: `Resumed execution run "${snapshot.title}".`,
|
|
1114
|
+
})
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
async syncRunGraph(params: {
|
|
1118
|
+
tx: DatabaseTransaction
|
|
1119
|
+
run: PlanRunRecord
|
|
1120
|
+
spec: PlanSpecRecord
|
|
1121
|
+
nodeSpecs: PlanNodeSpecRecord[]
|
|
1122
|
+
nodeRuns: PlanNodeRunRecord[]
|
|
1123
|
+
artifacts: Array<{
|
|
1124
|
+
id: RecordIdInput
|
|
1125
|
+
nodeId: string
|
|
1126
|
+
name: string
|
|
1127
|
+
kind: string
|
|
1128
|
+
pointer: string
|
|
1129
|
+
schemaRef?: string
|
|
1130
|
+
payload?: unknown
|
|
1131
|
+
}>
|
|
1132
|
+
emittedBy: string
|
|
1133
|
+
}): Promise<{
|
|
1134
|
+
run: PlanRunRecord
|
|
1135
|
+
nodeRuns: PlanNodeRunRecord[]
|
|
1136
|
+
artifacts: Array<{
|
|
1137
|
+
id: RecordIdInput
|
|
1138
|
+
nodeId: string
|
|
1139
|
+
name: string
|
|
1140
|
+
kind: string
|
|
1141
|
+
pointer: string
|
|
1142
|
+
schemaRef?: string
|
|
1143
|
+
payload?: unknown
|
|
1144
|
+
}>
|
|
1145
|
+
}> {
|
|
1146
|
+
let currentRun = params.run
|
|
1147
|
+
let currentNodeRuns = [...params.nodeRuns]
|
|
1148
|
+
const currentArtifacts = [...params.artifacts]
|
|
1149
|
+
const sortedNodeSpecs = [...params.nodeSpecs].sort((left, right) => left.position - right.position)
|
|
1150
|
+
|
|
1151
|
+
const replaceNodeRun = (nextNodeRun: PlanNodeRunRecord) => {
|
|
1152
|
+
currentNodeRuns = currentNodeRuns.map((candidate) =>
|
|
1153
|
+
candidate.nodeId === nextNodeRun.nodeId ? nextNodeRun : candidate,
|
|
1154
|
+
)
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
const getNodeRunsById = () => new Map(currentNodeRuns.map((nodeRun) => [nodeRun.nodeId, nodeRun]))
|
|
1158
|
+
const getArtifactsByNodeId = () =>
|
|
1159
|
+
currentArtifacts.reduce((groups, artifact) => {
|
|
1160
|
+
const list = groups.get(artifact.nodeId) ?? []
|
|
1161
|
+
list.push(artifact)
|
|
1162
|
+
groups.set(artifact.nodeId, list)
|
|
1163
|
+
return groups
|
|
1164
|
+
}, new Map<string, typeof currentArtifacts>())
|
|
1165
|
+
|
|
1166
|
+
let changed = true
|
|
1167
|
+
while (changed) {
|
|
1168
|
+
changed = false
|
|
1169
|
+
const nodeRunsById = getNodeRunsById()
|
|
1170
|
+
const artifactsByNodeId = getArtifactsByNodeId()
|
|
1171
|
+
|
|
1172
|
+
for (const nodeSpec of sortedNodeSpecs) {
|
|
1173
|
+
const nodeRun = nodeRunsById.get(nodeSpec.nodeId)
|
|
1174
|
+
if (!nodeRun || nodeRun.status !== 'pending') continue
|
|
1175
|
+
|
|
1176
|
+
const upstreamRuns = nodeSpec.upstreamNodeIds
|
|
1177
|
+
.map((nodeId) => nodeRunsById.get(nodeId))
|
|
1178
|
+
.filter(Boolean) as PlanNodeRunRecord[]
|
|
1179
|
+
if (
|
|
1180
|
+
nodeSpec.upstreamNodeIds.length > 0 &&
|
|
1181
|
+
!upstreamRuns.every((upstreamRun) => isSuccessfulTerminalStatus(upstreamRun.status))
|
|
1182
|
+
) {
|
|
1183
|
+
continue
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
const activeIncomingEdges = params.spec.edges.filter((edge) => {
|
|
1187
|
+
if (edge.target !== nodeSpec.nodeId) return false
|
|
1188
|
+
const sourceRun = nodeRunsById.get(edge.source)
|
|
1189
|
+
if (!sourceRun) return false
|
|
1190
|
+
const context = buildNodeContext({ nodeRun: sourceRun, artifacts: artifactsByNodeId.get(edge.source) ?? [] })
|
|
1191
|
+
return evaluateCondition(edge.when, context)
|
|
1192
|
+
})
|
|
1193
|
+
|
|
1194
|
+
if (nodeSpec.upstreamNodeIds.length > 0 && activeIncomingEdges.length === 0) {
|
|
1195
|
+
const skippedNodeRun = PlanNodeRunSchema.parse(
|
|
1196
|
+
await params.tx
|
|
1197
|
+
.update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
|
|
1198
|
+
.content(
|
|
1199
|
+
toNodeRunData(nodeRun, {
|
|
1200
|
+
status: 'skipped',
|
|
1201
|
+
completedAt: new Date(),
|
|
1202
|
+
blockedReason: null,
|
|
1203
|
+
failureClass: null,
|
|
1204
|
+
}),
|
|
1205
|
+
)
|
|
1206
|
+
.output('after'),
|
|
1207
|
+
)
|
|
1208
|
+
replaceNodeRun(skippedNodeRun)
|
|
1209
|
+
await this.emitEvent({
|
|
1210
|
+
tx: params.tx,
|
|
1211
|
+
run: currentRun,
|
|
1212
|
+
spec: params.spec,
|
|
1213
|
+
nodeId: skippedNodeRun.nodeId,
|
|
1214
|
+
eventType: 'node-skipped',
|
|
1215
|
+
fromStatus: nodeRun.status,
|
|
1216
|
+
toStatus: skippedNodeRun.status,
|
|
1217
|
+
message: `Node "${nodeSpec.label}" was skipped because no inbound branch was activated.`,
|
|
1218
|
+
emittedBy: params.emittedBy,
|
|
1219
|
+
})
|
|
1220
|
+
changed = true
|
|
1221
|
+
continue
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
const resolvedInput = this.buildResolvedInput({ spec: params.spec, nodeSpec, nodeRunsById, artifactsByNodeId })
|
|
1225
|
+
const readyNodeRun = PlanNodeRunSchema.parse(
|
|
1226
|
+
await params.tx
|
|
1227
|
+
.update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
|
|
1228
|
+
.content(toNodeRunData(nodeRun, { status: 'ready', resolvedInput, readyAt: new Date() }))
|
|
1229
|
+
.output('after'),
|
|
1230
|
+
)
|
|
1231
|
+
replaceNodeRun(readyNodeRun)
|
|
1232
|
+
await this.emitEvent({
|
|
1233
|
+
tx: params.tx,
|
|
1234
|
+
run: currentRun,
|
|
1235
|
+
spec: params.spec,
|
|
1236
|
+
nodeId: readyNodeRun.nodeId,
|
|
1237
|
+
eventType: 'node-ready',
|
|
1238
|
+
fromStatus: nodeRun.status,
|
|
1239
|
+
toStatus: readyNodeRun.status,
|
|
1240
|
+
message: `Node "${nodeSpec.label}" is ready to execute.`,
|
|
1241
|
+
emittedBy: params.emittedBy,
|
|
1242
|
+
})
|
|
1243
|
+
changed = true
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
const readyStructuralNodes = sortedNodeSpecs.filter((nodeSpec) => {
|
|
1247
|
+
const nodeRun = getNodeRunsById().get(nodeSpec.nodeId)
|
|
1248
|
+
return nodeRun?.status === 'ready' && isStructuralNodeType(nodeSpec.type)
|
|
1249
|
+
})
|
|
1250
|
+
|
|
1251
|
+
for (const nodeSpec of readyStructuralNodes) {
|
|
1252
|
+
const nodeRun = getNodeRunsById().get(nodeSpec.nodeId)
|
|
1253
|
+
if (!nodeRun) continue
|
|
1254
|
+
|
|
1255
|
+
const completedNodeRun = PlanNodeRunSchema.parse(
|
|
1256
|
+
await params.tx
|
|
1257
|
+
.update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
|
|
1258
|
+
.content(
|
|
1259
|
+
toNodeRunData(nodeRun, {
|
|
1260
|
+
status: 'completed',
|
|
1261
|
+
startedAt: nodeRun.startedAt ?? new Date(),
|
|
1262
|
+
completedAt: new Date(),
|
|
1263
|
+
}),
|
|
1264
|
+
)
|
|
1265
|
+
.output('after'),
|
|
1266
|
+
)
|
|
1267
|
+
replaceNodeRun(completedNodeRun)
|
|
1268
|
+
await this.emitEvent({
|
|
1269
|
+
tx: params.tx,
|
|
1270
|
+
run: currentRun,
|
|
1271
|
+
spec: params.spec,
|
|
1272
|
+
nodeId: completedNodeRun.nodeId,
|
|
1273
|
+
eventType: 'node-auto-completed',
|
|
1274
|
+
fromStatus: nodeRun.status,
|
|
1275
|
+
toStatus: completedNodeRun.status,
|
|
1276
|
+
message: `Structural node "${nodeSpec.label}" auto-completed.`,
|
|
1277
|
+
emittedBy: params.emittedBy,
|
|
1278
|
+
})
|
|
1279
|
+
changed = true
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
const nodeRunsById = getNodeRunsById()
|
|
1284
|
+
const activeRunningNode = currentNodeRuns.find((nodeRun) => nodeRun.status === 'running')
|
|
1285
|
+
const activeHumanNode = currentNodeRuns.find((nodeRun) => nodeRun.status === 'awaiting-human')
|
|
1286
|
+
|
|
1287
|
+
if (!activeRunningNode && !activeHumanNode) {
|
|
1288
|
+
const nextHumanNodeSpec = sortedNodeSpecs.find((nodeSpec) => {
|
|
1289
|
+
const nodeRun = nodeRunsById.get(nodeSpec.nodeId)
|
|
1290
|
+
return nodeRun?.status === 'ready' && isHumanNodeType(nodeSpec.type)
|
|
1291
|
+
})
|
|
1292
|
+
|
|
1293
|
+
if (nextHumanNodeSpec) {
|
|
1294
|
+
const nodeRun = nodeRunsById.get(nextHumanNodeSpec.nodeId)
|
|
1295
|
+
if (!nodeRun) {
|
|
1296
|
+
throw new Error(`Expected ready node run for "${nextHumanNodeSpec.nodeId}".`)
|
|
1297
|
+
}
|
|
1298
|
+
const awaitingHumanNodeRun = PlanNodeRunSchema.parse(
|
|
1299
|
+
await params.tx
|
|
1300
|
+
.update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
|
|
1301
|
+
.content(toNodeRunData(nodeRun, { status: 'awaiting-human', startedAt: nodeRun.startedAt ?? new Date() }))
|
|
1302
|
+
.output('after'),
|
|
1303
|
+
)
|
|
1304
|
+
replaceNodeRun(awaitingHumanNodeRun)
|
|
1305
|
+
|
|
1306
|
+
const approval = await planApprovalService.createPendingApproval({
|
|
1307
|
+
tx: params.tx,
|
|
1308
|
+
runId: currentRun.id,
|
|
1309
|
+
nodeRunId: awaitingHumanNodeRun.id,
|
|
1310
|
+
nodeId: awaitingHumanNodeRun.nodeId,
|
|
1311
|
+
requestedBy: params.emittedBy,
|
|
1312
|
+
presented: {
|
|
1313
|
+
nodeId: nextHumanNodeSpec.nodeId,
|
|
1314
|
+
label: nextHumanNodeSpec.label,
|
|
1315
|
+
objective: nextHumanNodeSpec.objective,
|
|
1316
|
+
instructions: nextHumanNodeSpec.instructions,
|
|
1317
|
+
deliverables: nextHumanNodeSpec.deliverables,
|
|
1318
|
+
successCriteria: nextHumanNodeSpec.successCriteria,
|
|
1319
|
+
resolvedInput: awaitingHumanNodeRun.resolvedInput ?? {},
|
|
1320
|
+
},
|
|
1321
|
+
})
|
|
1322
|
+
|
|
1323
|
+
currentRun = await this.replaceRun(params.tx, currentRun, {
|
|
1324
|
+
status: 'awaiting-human',
|
|
1325
|
+
currentNodeId: awaitingHumanNodeRun.nodeId,
|
|
1326
|
+
waitingNodeId: awaitingHumanNodeRun.nodeId,
|
|
1327
|
+
readyNodeIds: currentNodeRuns
|
|
1328
|
+
.filter((candidate) => candidate.status === 'ready' && candidate.nodeId !== awaitingHumanNodeRun.nodeId)
|
|
1329
|
+
.map((candidate) => candidate.nodeId),
|
|
1330
|
+
})
|
|
1331
|
+
|
|
1332
|
+
await this.emitEvent({
|
|
1333
|
+
tx: params.tx,
|
|
1334
|
+
run: currentRun,
|
|
1335
|
+
spec: params.spec,
|
|
1336
|
+
nodeId: awaitingHumanNodeRun.nodeId,
|
|
1337
|
+
approvalId: approval.id,
|
|
1338
|
+
eventType: 'approval-requested',
|
|
1339
|
+
fromStatus: params.run.status,
|
|
1340
|
+
toStatus: currentRun.status,
|
|
1341
|
+
message: `Node "${nextHumanNodeSpec.label}" is awaiting human input.`,
|
|
1342
|
+
emittedBy: params.emittedBy,
|
|
1343
|
+
})
|
|
1344
|
+
} else {
|
|
1345
|
+
const nextActionNodeSpec = sortedNodeSpecs.find((nodeSpec) => {
|
|
1346
|
+
const nodeRun = nodeRunsById.get(nodeSpec.nodeId)
|
|
1347
|
+
return nodeRun?.status === 'ready' && !isStructuralNodeType(nodeSpec.type)
|
|
1348
|
+
})
|
|
1349
|
+
|
|
1350
|
+
if (nextActionNodeSpec) {
|
|
1351
|
+
const nodeRun = nodeRunsById.get(nextActionNodeSpec.nodeId)
|
|
1352
|
+
if (!nodeRun) {
|
|
1353
|
+
throw new Error(`Expected ready node run for "${nextActionNodeSpec.nodeId}".`)
|
|
1354
|
+
}
|
|
1355
|
+
const runningNodeRun = PlanNodeRunSchema.parse(
|
|
1356
|
+
await params.tx
|
|
1357
|
+
.update(ensureRecordId(nodeRun.id, TABLES.PLAN_NODE_RUN))
|
|
1358
|
+
.content(toNodeRunData(nodeRun, { status: 'running', startedAt: nodeRun.startedAt ?? new Date() }))
|
|
1359
|
+
.output('after'),
|
|
1360
|
+
)
|
|
1361
|
+
replaceNodeRun(runningNodeRun)
|
|
1362
|
+
|
|
1363
|
+
currentRun = await this.replaceRun(params.tx, currentRun, {
|
|
1364
|
+
status: 'running',
|
|
1365
|
+
currentNodeId: runningNodeRun.nodeId,
|
|
1366
|
+
waitingNodeId: null,
|
|
1367
|
+
readyNodeIds: currentNodeRuns
|
|
1368
|
+
.filter((candidate) => candidate.status === 'ready' && candidate.nodeId !== runningNodeRun.nodeId)
|
|
1369
|
+
.map((candidate) => candidate.nodeId),
|
|
1370
|
+
})
|
|
1371
|
+
|
|
1372
|
+
await this.emitEvent({
|
|
1373
|
+
tx: params.tx,
|
|
1374
|
+
run: currentRun,
|
|
1375
|
+
spec: params.spec,
|
|
1376
|
+
nodeId: runningNodeRun.nodeId,
|
|
1377
|
+
eventType: 'node-running',
|
|
1378
|
+
fromStatus: nodeRun.status,
|
|
1379
|
+
toStatus: runningNodeRun.status,
|
|
1380
|
+
message: `Node "${nextActionNodeSpec.label}" is now running.`,
|
|
1381
|
+
emittedBy: params.emittedBy,
|
|
1382
|
+
})
|
|
1383
|
+
} else if (currentNodeRuns.every((nodeRun) => isSuccessfulTerminalStatus(nodeRun.status))) {
|
|
1384
|
+
currentRun = await this.replaceRun(params.tx, currentRun, {
|
|
1385
|
+
status: 'completed',
|
|
1386
|
+
currentNodeId: null,
|
|
1387
|
+
waitingNodeId: null,
|
|
1388
|
+
readyNodeIds: [],
|
|
1389
|
+
completedAt: new Date(),
|
|
1390
|
+
})
|
|
1391
|
+
|
|
1392
|
+
await this.emitEvent({
|
|
1393
|
+
tx: params.tx,
|
|
1394
|
+
run: currentRun,
|
|
1395
|
+
spec: params.spec,
|
|
1396
|
+
eventType: 'run-status-changed',
|
|
1397
|
+
fromStatus: params.run.status,
|
|
1398
|
+
toStatus: currentRun.status,
|
|
1399
|
+
message: `Run "${params.spec.title}" completed.`,
|
|
1400
|
+
emittedBy: params.emittedBy,
|
|
1401
|
+
})
|
|
1402
|
+
} else {
|
|
1403
|
+
currentRun = await this.replaceRun(params.tx, currentRun, {
|
|
1404
|
+
status: 'blocked',
|
|
1405
|
+
currentNodeId: null,
|
|
1406
|
+
waitingNodeId: null,
|
|
1407
|
+
readyNodeIds: currentNodeRuns
|
|
1408
|
+
.filter((candidate) => candidate.status === 'ready')
|
|
1409
|
+
.map((candidate) => candidate.nodeId),
|
|
1410
|
+
})
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
} else {
|
|
1414
|
+
currentRun = await this.replaceRun(params.tx, currentRun, {
|
|
1415
|
+
status: activeHumanNode ? 'awaiting-human' : 'running',
|
|
1416
|
+
currentNodeId: activeHumanNode?.nodeId ?? activeRunningNode?.nodeId ?? null,
|
|
1417
|
+
waitingNodeId: activeHumanNode?.nodeId ?? null,
|
|
1418
|
+
readyNodeIds: currentNodeRuns
|
|
1419
|
+
.filter((candidate) => candidate.status === 'ready')
|
|
1420
|
+
.map((candidate) => candidate.nodeId),
|
|
1421
|
+
})
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
return { run: currentRun, nodeRuns: currentNodeRuns, artifacts: currentArtifacts }
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
private resolveFailureAction(nodeSpec: PlanNodeSpecRecord, failureClass: PlanFailureClass | null): PlanFailureAction {
|
|
1428
|
+
if (!failureClass) return 'abort'
|
|
1429
|
+
|
|
1430
|
+
const matchedRule = nodeSpec.failurePolicy.find((rule) => rule.on === failureClass)
|
|
1431
|
+
return matchedRule?.action ?? 'abort'
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
private buildResolvedInput(params: {
|
|
1435
|
+
spec: PlanSpecRecord
|
|
1436
|
+
nodeSpec: PlanNodeSpecRecord
|
|
1437
|
+
nodeRunsById: Map<string, PlanNodeRunRecord>
|
|
1438
|
+
artifactsByNodeId: Map<
|
|
1439
|
+
string,
|
|
1440
|
+
Array<{ nodeId: string; name: string; kind: string; pointer: string; schemaRef?: string; payload?: unknown }>
|
|
1441
|
+
>
|
|
1442
|
+
}) {
|
|
1443
|
+
const resolvedInput: Record<string, unknown> = {}
|
|
1444
|
+
|
|
1445
|
+
for (const edge of params.spec.edges.filter((candidate) => candidate.target === params.nodeSpec.nodeId)) {
|
|
1446
|
+
const sourceRun = params.nodeRunsById.get(edge.source)
|
|
1447
|
+
if (!sourceRun || !isSuccessfulTerminalStatus(sourceRun.status)) continue
|
|
1448
|
+
|
|
1449
|
+
const context = buildNodeContext({
|
|
1450
|
+
nodeRun: sourceRun,
|
|
1451
|
+
artifacts: params.artifactsByNodeId.get(edge.source) ?? [],
|
|
1452
|
+
})
|
|
1453
|
+
if (!evaluateCondition(edge.when, context)) continue
|
|
1454
|
+
|
|
1455
|
+
for (const [targetPath, sourcePath] of Object.entries(edge.map)) {
|
|
1456
|
+
const value = readPathValue(context, sourcePath)
|
|
1457
|
+
if (value !== undefined) {
|
|
1458
|
+
setPathValue(resolvedInput, targetPath, value)
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
return resolvedInput
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
private async createAttempt(params: {
|
|
1467
|
+
tx: DatabaseTransaction
|
|
1468
|
+
run: PlanRunRecord
|
|
1469
|
+
nodeRun: PlanNodeRunRecord
|
|
1470
|
+
emittedBy: string
|
|
1471
|
+
result: PlanNodeResultSubmission
|
|
1472
|
+
status: 'completed' | 'failed'
|
|
1473
|
+
failureClass: PlanFailureClass | null
|
|
1474
|
+
}) {
|
|
1475
|
+
const attemptId = new RecordId(TABLES.PLAN_NODE_ATTEMPT, Bun.randomUUIDv7())
|
|
1476
|
+
return PlanNodeAttemptSchema.parse(
|
|
1477
|
+
await params.tx
|
|
1478
|
+
.create(attemptId)
|
|
1479
|
+
.content({
|
|
1480
|
+
runId: ensureRecordId(params.run.id, TABLES.PLAN_RUN),
|
|
1481
|
+
nodeRunId: ensureRecordId(params.nodeRun.id, TABLES.PLAN_NODE_RUN),
|
|
1482
|
+
nodeId: params.nodeRun.nodeId,
|
|
1483
|
+
emittedBy: params.emittedBy,
|
|
1484
|
+
status: params.status,
|
|
1485
|
+
...(params.result.structuredOutput ? { structuredOutput: params.result.structuredOutput } : {}),
|
|
1486
|
+
...(params.result.notes ? { notes: params.result.notes } : {}),
|
|
1487
|
+
validationIssueIds: [],
|
|
1488
|
+
...(params.failureClass ? { failureClass: params.failureClass } : {}),
|
|
1489
|
+
})
|
|
1490
|
+
.output('after'),
|
|
1491
|
+
)
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
private async persistValidationIssues(params: {
|
|
1495
|
+
tx: DatabaseTransaction
|
|
1496
|
+
run: PlanRunRecord
|
|
1497
|
+
spec: PlanSpecRecord
|
|
1498
|
+
attemptId: RecordIdInput
|
|
1499
|
+
nodeId: string
|
|
1500
|
+
issues: PlanValidationIssueInput[]
|
|
1501
|
+
}): Promise<PlanValidationIssueRecord[]> {
|
|
1502
|
+
const records: PlanValidationIssueRecord[] = []
|
|
1503
|
+
|
|
1504
|
+
for (const issue of params.issues) {
|
|
1505
|
+
const issueId = new RecordId(TABLES.PLAN_VALIDATION_ISSUE, Bun.randomUUIDv7())
|
|
1506
|
+
const created = await params.tx
|
|
1507
|
+
.create(issueId)
|
|
1508
|
+
.content({
|
|
1509
|
+
planSpecId: ensureRecordId(params.spec.id, TABLES.PLAN_SPEC),
|
|
1510
|
+
runId: ensureRecordId(params.run.id, TABLES.PLAN_RUN),
|
|
1511
|
+
nodeId: issue.nodeId ?? params.nodeId,
|
|
1512
|
+
attemptId: ensureRecordId(params.attemptId, TABLES.PLAN_NODE_ATTEMPT),
|
|
1513
|
+
severity: issue.severity,
|
|
1514
|
+
code: issue.code,
|
|
1515
|
+
message: issue.message,
|
|
1516
|
+
...(issue.detail ? { detail: issue.detail } : {}),
|
|
1517
|
+
})
|
|
1518
|
+
.output('after')
|
|
1519
|
+
|
|
1520
|
+
records.push(PlanValidationIssueSchema.parse(created))
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
return records
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
private async emitEvent(params: {
|
|
1527
|
+
tx: DatabaseTransaction
|
|
1528
|
+
run: PlanRunRecord
|
|
1529
|
+
spec: PlanSpecRecord
|
|
1530
|
+
eventType: PlanEventType
|
|
1531
|
+
message: string
|
|
1532
|
+
emittedBy: string
|
|
1533
|
+
nodeId?: string
|
|
1534
|
+
attemptId?: RecordIdInput
|
|
1535
|
+
approvalId?: RecordIdInput
|
|
1536
|
+
fromStatus?: string
|
|
1537
|
+
toStatus?: string
|
|
1538
|
+
detail?: Record<string, unknown>
|
|
1539
|
+
}): Promise<PlanEventRecord> {
|
|
1540
|
+
const eventId = new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7())
|
|
1541
|
+
const created = await params.tx
|
|
1542
|
+
.create(eventId)
|
|
1543
|
+
.content({
|
|
1544
|
+
planSpecId: ensureRecordId(params.spec.id, TABLES.PLAN_SPEC),
|
|
1545
|
+
runId: ensureRecordId(params.run.id, TABLES.PLAN_RUN),
|
|
1546
|
+
eventType: params.eventType,
|
|
1547
|
+
message: params.message,
|
|
1548
|
+
emittedBy: params.emittedBy,
|
|
1549
|
+
...(params.nodeId ? { nodeId: params.nodeId } : {}),
|
|
1550
|
+
...(params.attemptId ? { attemptId: ensureRecordId(params.attemptId, TABLES.PLAN_NODE_ATTEMPT) } : {}),
|
|
1551
|
+
...(params.approvalId ? { approvalId: ensureRecordId(params.approvalId, TABLES.PLAN_APPROVAL) } : {}),
|
|
1552
|
+
...(params.fromStatus ? { fromStatus: params.fromStatus } : {}),
|
|
1553
|
+
...(params.toStatus ? { toStatus: params.toStatus } : {}),
|
|
1554
|
+
...(params.detail ? { detail: params.detail } : {}),
|
|
1555
|
+
})
|
|
1556
|
+
.output('after')
|
|
1557
|
+
|
|
1558
|
+
return PlanEventSchema.parse(created)
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
private async replaceRun(tx: DatabaseTransaction, run: PlanRunRecord, patch: PlanRunUpdate): Promise<PlanRunRecord> {
|
|
1562
|
+
return PlanRunSchema.parse(
|
|
1563
|
+
await tx.update(ensureRecordId(run.id, TABLES.PLAN_RUN)).content(toRunData(run, patch)).output('after'),
|
|
1564
|
+
)
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
private async saveCheckpoint(params: {
|
|
1568
|
+
tx: DatabaseTransaction
|
|
1569
|
+
run: PlanRunRecord
|
|
1570
|
+
nodeRuns: PlanNodeRunRecord[]
|
|
1571
|
+
artifacts: Array<{ id: RecordIdInput; nodeId: string }>
|
|
1572
|
+
sequence: number
|
|
1573
|
+
reason: string
|
|
1574
|
+
}) {
|
|
1575
|
+
const checkpoint = await planCheckpointService.createCheckpoint({
|
|
1576
|
+
tx: params.tx,
|
|
1577
|
+
runId: params.run.id,
|
|
1578
|
+
sequence: params.sequence,
|
|
1579
|
+
runStatus: params.run.status,
|
|
1580
|
+
readyNodeIds: params.run.readyNodeIds,
|
|
1581
|
+
activeNodeIds: params.run.currentNodeId ? [params.run.currentNodeId] : [],
|
|
1582
|
+
artifactIds: params.artifacts.map((artifact) => artifact.id),
|
|
1583
|
+
lastCompletedNodeIds: params.nodeRuns
|
|
1584
|
+
.filter((nodeRun) => nodeRun.status === 'completed' || nodeRun.status === 'partial')
|
|
1585
|
+
.map((nodeRun) => nodeRun.nodeId),
|
|
1586
|
+
snapshot: {
|
|
1587
|
+
reason: params.reason,
|
|
1588
|
+
runStatus: params.run.status,
|
|
1589
|
+
currentNodeId: params.run.currentNodeId,
|
|
1590
|
+
waitingNodeId: params.run.waitingNodeId,
|
|
1591
|
+
readyNodeIds: params.run.readyNodeIds,
|
|
1592
|
+
nodeStatuses: Object.fromEntries(params.nodeRuns.map((nodeRun) => [nodeRun.nodeId, nodeRun.status])),
|
|
1593
|
+
},
|
|
1594
|
+
})
|
|
1595
|
+
|
|
1596
|
+
await this.emitEvent({
|
|
1597
|
+
tx: params.tx,
|
|
1598
|
+
run: params.run,
|
|
1599
|
+
spec: await planRunService.getPlanSpecById(params.run.planSpecId),
|
|
1600
|
+
eventType: 'checkpoint-saved',
|
|
1601
|
+
message: `Saved checkpoint ${checkpoint.sequence}.`,
|
|
1602
|
+
detail: { checkpointId: recordIdToString(checkpoint.id, TABLES.PLAN_CHECKPOINT), reason: params.reason },
|
|
1603
|
+
emittedBy: 'system',
|
|
1604
|
+
})
|
|
1605
|
+
|
|
1606
|
+
return checkpoint
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
private async attachCheckpoint(
|
|
1610
|
+
tx: DatabaseTransaction,
|
|
1611
|
+
run: PlanRunRecord,
|
|
1612
|
+
checkpoint: RecordIdInput | { id: RecordIdInput },
|
|
1613
|
+
) {
|
|
1614
|
+
const checkpointId = checkpoint && typeof checkpoint === 'object' && 'id' in checkpoint ? checkpoint.id : checkpoint
|
|
1615
|
+
|
|
1616
|
+
await tx
|
|
1617
|
+
.update(ensureRecordId(run.id, TABLES.PLAN_RUN))
|
|
1618
|
+
.content(toRunData(run, { lastCheckpointId: checkpointId }))
|
|
1619
|
+
.output('after')
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
export const planExecutorService = new PlanExecutorService()
|