@lota-sdk/core 0.4.3 → 0.4.4
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/02_execution_plan.surql +4 -0
- package/package.json +2 -2
- package/src/runtime/execution-plan.ts +2 -7
- package/src/services/agent-activity.service.ts +35 -5
- package/src/services/artifact.service.ts +7 -7
- package/src/services/execution-plan.service.ts +397 -41
- package/src/services/global-orchestrator.service.ts +1 -1
- package/src/services/ownership-dispatcher.service.ts +1 -1
- package/src/services/plan-run-data.ts +70 -4
- package/src/services/plan-run.service.ts +28 -1
- package/src/services/plan-template.service.ts +6 -0
- package/src/tools/execution-plan.tool.ts +13 -2
- package/src/tools/index.ts +1 -0
- package/src/tools/plan-approval.tool.ts +66 -0
|
@@ -86,7 +86,9 @@ DEFINE TABLE IF NOT EXISTS planRun SCHEMAFULL;
|
|
|
86
86
|
DEFINE FIELD IF NOT EXISTS planSpecId ON TABLE planRun TYPE record<planSpec> REFERENCE ON DELETE CASCADE;
|
|
87
87
|
DEFINE FIELD IF NOT EXISTS organizationId ON TABLE planRun TYPE record<organization>;
|
|
88
88
|
DEFINE FIELD IF NOT EXISTS threadId ON TABLE planRun TYPE record<thread> REFERENCE ON DELETE CASCADE;
|
|
89
|
+
DEFINE FIELD IF NOT EXISTS sourceThreadId ON TABLE planRun TYPE option<record<thread>>;
|
|
89
90
|
DEFINE FIELD IF NOT EXISTS leadAgentId ON TABLE planRun TYPE string;
|
|
91
|
+
DEFINE FIELD IF NOT EXISTS createdByAgentId ON TABLE planRun TYPE option<string>;
|
|
90
92
|
DEFINE FIELD IF NOT EXISTS status ON TABLE planRun TYPE string;
|
|
91
93
|
DEFINE FIELD IF NOT EXISTS currentNodeId ON TABLE planRun TYPE option<string>;
|
|
92
94
|
DEFINE FIELD IF NOT EXISTS waitingNodeId ON TABLE planRun TYPE option<string>;
|
|
@@ -103,7 +105,9 @@ DEFINE FIELD IF NOT EXISTS completedAt ON TABLE planRun TYPE option<datetime>;
|
|
|
103
105
|
|
|
104
106
|
DEFINE INDEX IF NOT EXISTS planRunOrgIdx ON TABLE planRun COLUMNS organizationId;
|
|
105
107
|
DEFINE INDEX IF NOT EXISTS planRunThreadIdx ON TABLE planRun COLUMNS threadId;
|
|
108
|
+
DEFINE INDEX IF NOT EXISTS planRunSourceThreadIdx ON TABLE planRun COLUMNS sourceThreadId;
|
|
106
109
|
DEFINE INDEX IF NOT EXISTS planRunThreadStatusIdx ON TABLE planRun COLUMNS threadId, status;
|
|
110
|
+
DEFINE INDEX IF NOT EXISTS planRunSourceThreadStatusIdx ON TABLE planRun COLUMNS sourceThreadId, status;
|
|
107
111
|
DEFINE INDEX IF NOT EXISTS planRunSpecIdx ON TABLE planRun COLUMNS planSpecId;
|
|
108
112
|
|
|
109
113
|
DEFINE TABLE IF NOT EXISTS planNodeRun SCHEMAFULL;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lota-sdk/core",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@chat-adapter/slack": "^4.23.0",
|
|
33
33
|
"@chat-adapter/state-ioredis": "^4.23.0",
|
|
34
34
|
"@logtape/logtape": "^2.0.5",
|
|
35
|
-
"@lota-sdk/shared": "0.4.
|
|
35
|
+
"@lota-sdk/shared": "0.4.4",
|
|
36
36
|
"@mendable/firecrawl-js": "^4.18.1",
|
|
37
37
|
"@surrealdb/node": "^3.0.3",
|
|
38
38
|
"ai": "^6.0.145",
|
|
@@ -16,9 +16,7 @@ function toExecutionPlanPromptSummaries(plans: SerializableExecutionPlan[]): Exe
|
|
|
16
16
|
return plans.map(({ runId, title }) => ({ runId, title }))
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
function formatExecutionPlansForPrompt(plans: SerializableExecutionPlan[]): string
|
|
20
|
-
if (plans.length === 0) return undefined
|
|
21
|
-
|
|
19
|
+
function formatExecutionPlansForPrompt(plans: SerializableExecutionPlan[]): string {
|
|
22
20
|
const payload = { activePlans: toExecutionPlanPromptSummaries(plans), planCount: plans.length }
|
|
23
21
|
|
|
24
22
|
return ['<execution-plan-state>', JSON.stringify(payload, null, 2), '</execution-plan-state>'].join('\n')
|
|
@@ -26,10 +24,7 @@ function formatExecutionPlansForPrompt(plans: SerializableExecutionPlan[]): stri
|
|
|
26
24
|
|
|
27
25
|
export function buildExecutionPlanInstructionSections(plans: SerializableExecutionPlan[] | null | undefined): string[] {
|
|
28
26
|
const normalized = plans ?? []
|
|
29
|
-
|
|
30
|
-
const stateSection = formatExecutionPlansForPrompt(normalized)
|
|
31
|
-
if (stateSection) sections.push(stateSection)
|
|
32
|
-
return sections
|
|
27
|
+
return [EXECUTION_PLAN_AGENT_PROTOCOL_PROMPT, formatExecutionPlansForPrompt(normalized)]
|
|
33
28
|
}
|
|
34
29
|
|
|
35
30
|
export function createExecutionPlanInstructionSectionCache(params: {
|
|
@@ -41,6 +41,14 @@ type AgentActivityDeps = {
|
|
|
41
41
|
threadService: Pick<typeof threadService, 'listThreads'>
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
function isPendingPlanApproval(plan: SerializableExecutionPlan): boolean {
|
|
45
|
+
return plan.status === 'pending-approval'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function countPendingPlanApprovals(activePlans: ActivePlanEntry[]): number {
|
|
49
|
+
return activePlans.filter(({ plan }) => isPendingPlanApproval(plan)).length
|
|
50
|
+
}
|
|
51
|
+
|
|
44
52
|
function normalizeCardStatus(status: string): BoardColumnStatus {
|
|
45
53
|
if (status === 'pending' || status === 'scheduled') return 'ready'
|
|
46
54
|
if (status === 'partial') return 'completed'
|
|
@@ -145,9 +153,9 @@ export class AgentActivityService {
|
|
|
145
153
|
|
|
146
154
|
async getBoard(userRef: string, orgRef: string): Promise<PlanBoardResponse> {
|
|
147
155
|
const activePlans = await this.getAllActivePlans(userRef, orgRef)
|
|
148
|
-
const cards = activePlans
|
|
149
|
-
|
|
150
|
-
|
|
156
|
+
const cards = activePlans
|
|
157
|
+
.filter(({ plan }) => !isPendingPlanApproval(plan))
|
|
158
|
+
.flatMap(({ plan, thread }) => plan.nodes.map((node) => planNodeToCard(node, plan, thread.id, thread.title)))
|
|
151
159
|
|
|
152
160
|
const columns: PlanBoardColumn[] = BOARD_COLUMN_ORDER.map((status) => ({
|
|
153
161
|
status,
|
|
@@ -161,7 +169,7 @@ export class AgentActivityService {
|
|
|
161
169
|
totalNodes: cards.length,
|
|
162
170
|
completedNodes: cards.filter((card) => card.status === 'completed').length,
|
|
163
171
|
activePlanCount: activePlans.length,
|
|
164
|
-
pendingApprovalCount: cards.filter((card) => card.hasApproval).length,
|
|
172
|
+
pendingApprovalCount: countPendingPlanApprovals(activePlans) + cards.filter((card) => card.hasApproval).length,
|
|
165
173
|
},
|
|
166
174
|
}
|
|
167
175
|
}
|
|
@@ -187,8 +195,13 @@ export class AgentActivityService {
|
|
|
187
195
|
async getMyTasks(userRef: string, orgRef: string): Promise<MyTasksResponse> {
|
|
188
196
|
const activePlans = await this.getAllActivePlans(userRef, orgRef)
|
|
189
197
|
const tasks: PlanNodeCard[] = []
|
|
198
|
+
const pendingPlanApprovalCount = countPendingPlanApprovals(activePlans)
|
|
190
199
|
|
|
191
200
|
for (const { plan, thread } of activePlans) {
|
|
201
|
+
if (isPendingPlanApproval(plan)) {
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
|
|
192
205
|
for (const node of plan.nodes) {
|
|
193
206
|
const humanOwned = node.owner.executorType === 'user'
|
|
194
207
|
const awaitingHuman = node.status === 'awaiting-human'
|
|
@@ -198,7 +211,7 @@ export class AgentActivityService {
|
|
|
198
211
|
}
|
|
199
212
|
}
|
|
200
213
|
|
|
201
|
-
return { tasks, pendingApprovalCount: tasks.filter((task) => task.hasApproval).length }
|
|
214
|
+
return { tasks, pendingApprovalCount: pendingPlanApprovalCount + tasks.filter((task) => task.hasApproval).length }
|
|
202
215
|
}
|
|
203
216
|
|
|
204
217
|
async getAgentActivity(userRef: string, orgRef: string): Promise<AgentActivityResponse> {
|
|
@@ -210,6 +223,23 @@ export class AgentActivityService {
|
|
|
210
223
|
}
|
|
211
224
|
|
|
212
225
|
for (const { plan, thread } of activePlans) {
|
|
226
|
+
if (isPendingPlanApproval(plan)) {
|
|
227
|
+
if (plan.leadAgentId.trim()) {
|
|
228
|
+
const leadEntry = this.ensureEntry(activityByAgent, plan.leadAgentId)
|
|
229
|
+
leadEntry.isLeadingActivePlan = true
|
|
230
|
+
this.ensureProjectEntry(leadEntry.projects, {
|
|
231
|
+
threadId: thread.id,
|
|
232
|
+
threadTitle: thread.title,
|
|
233
|
+
planRunId: plan.runId,
|
|
234
|
+
planTitle: plan.title,
|
|
235
|
+
status: plan.status,
|
|
236
|
+
})
|
|
237
|
+
leadEntry.isRunning = leadEntry.isRunning || thread.isRunning
|
|
238
|
+
leadEntry.lastActiveAt = maxIsoDate(leadEntry.lastActiveAt, thread.updatedAt)
|
|
239
|
+
}
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
|
|
213
243
|
const involvedAgents = new Set<string>()
|
|
214
244
|
|
|
215
245
|
for (const node of plan.nodes) {
|
|
@@ -270,25 +270,25 @@ class ArtifactService {
|
|
|
270
270
|
|
|
271
271
|
let lastError: unknown = null
|
|
272
272
|
for (let attempt = 1; attempt <= ARTIFACT_PUBLISH_MAX_ATTEMPTS; attempt += 1) {
|
|
273
|
-
|
|
273
|
+
const publishAttemptState: { pendingStorageKey?: string } = {}
|
|
274
274
|
try {
|
|
275
275
|
return await databaseService.withTransaction(
|
|
276
276
|
async (tx) =>
|
|
277
277
|
await this.publishArtifactInTransaction(params, tx, {
|
|
278
278
|
onStorageWrite: (storageKey) => {
|
|
279
|
-
pendingStorageKey = storageKey
|
|
279
|
+
publishAttemptState.pendingStorageKey = storageKey
|
|
280
280
|
},
|
|
281
281
|
onStorageCleanup: (storageKey) => {
|
|
282
|
-
if (pendingStorageKey === storageKey) {
|
|
283
|
-
pendingStorageKey =
|
|
282
|
+
if (publishAttemptState.pendingStorageKey === storageKey) {
|
|
283
|
+
publishAttemptState.pendingStorageKey = undefined
|
|
284
284
|
}
|
|
285
285
|
},
|
|
286
286
|
}),
|
|
287
287
|
)
|
|
288
288
|
} catch (error) {
|
|
289
|
-
const storageKeyToCleanup = pendingStorageKey
|
|
290
|
-
pendingStorageKey =
|
|
291
|
-
if (storageKeyToCleanup
|
|
289
|
+
const storageKeyToCleanup = publishAttemptState.pendingStorageKey
|
|
290
|
+
publishAttemptState.pendingStorageKey = undefined
|
|
291
|
+
if (typeof storageKeyToCleanup === 'string') {
|
|
292
292
|
await generatedDocumentStorageService.deleteTextArtifact(storageKeyToCleanup).catch(() => undefined)
|
|
293
293
|
}
|
|
294
294
|
lastError = error
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
PlanDraft,
|
|
9
9
|
PlanNodeRunRecord,
|
|
10
10
|
PlanNodeSpecRecord,
|
|
11
|
+
PlanRunRecord,
|
|
11
12
|
PlanSpecRecord,
|
|
12
13
|
SerializableExecutionPlan,
|
|
13
14
|
SubmitPlanTurnResultArgs,
|
|
@@ -47,6 +48,25 @@ function aggregateBlockingIssues(issues: Array<{ code: string; message: string }
|
|
|
47
48
|
return issues.map((issue) => `${issue.code}: ${issue.message}`).join(' | ')
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
function hasCrossThreadSourceContext(sourceThreadId: RecordIdInput | undefined, threadId: RecordIdInput): boolean {
|
|
52
|
+
if (!sourceThreadId) {
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return recordIdToString(sourceThreadId, TABLES.THREAD) !== recordIdToString(threadId, TABLES.THREAD)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isPlanVisibleInThreadContext(
|
|
60
|
+
threadId: RecordIdInput,
|
|
61
|
+
params: { threadId: RecordIdInput; sourceThreadId?: RecordIdInput },
|
|
62
|
+
): boolean {
|
|
63
|
+
const currentThreadId = recordIdToString(threadId, TABLES.THREAD)
|
|
64
|
+
return (
|
|
65
|
+
currentThreadId === recordIdToString(params.threadId, TABLES.THREAD) ||
|
|
66
|
+
(params.sourceThreadId !== undefined && currentThreadId === recordIdToString(params.sourceThreadId, TABLES.THREAD))
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
50
70
|
function toSpecData(spec: PlanSpecRecord, patch: Partial<PlanSpecRecord> & { replacedSpecId?: RecordIdInput | null }) {
|
|
51
71
|
return {
|
|
52
72
|
organizationId: ensureRecordId(spec.organizationId, TABLES.ORGANIZATION),
|
|
@@ -168,17 +188,29 @@ class ExecutionPlanService {
|
|
|
168
188
|
const runs = await planRunService.getActiveRunRecords(threadId)
|
|
169
189
|
if (runs.length === 0) return []
|
|
170
190
|
|
|
171
|
-
return await
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
191
|
+
return await this.serializeRuns(runs, options)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async getPlansCreatedInContext(params: {
|
|
195
|
+
organizationId: RecordIdInput
|
|
196
|
+
sourceThreadId?: RecordIdInput
|
|
197
|
+
createdByAgentId?: string
|
|
198
|
+
statuses?: ReadonlyArray<PlanRunRecord['status']>
|
|
199
|
+
includeEvents?: boolean
|
|
200
|
+
includeArtifacts?: boolean
|
|
201
|
+
includeApprovals?: boolean
|
|
202
|
+
includeCheckpoints?: boolean
|
|
203
|
+
includeValidationIssues?: boolean
|
|
204
|
+
}): Promise<SerializableExecutionPlan[]> {
|
|
205
|
+
const runs = await planRunService.getRunsCreatedInContext({
|
|
206
|
+
organizationId: params.organizationId,
|
|
207
|
+
sourceThreadId: params.sourceThreadId,
|
|
208
|
+
createdByAgentId: params.createdByAgentId,
|
|
209
|
+
statuses: params.statuses,
|
|
210
|
+
})
|
|
211
|
+
if (runs.length === 0) return []
|
|
212
|
+
|
|
213
|
+
return await this.serializeRuns(runs, params)
|
|
182
214
|
}
|
|
183
215
|
|
|
184
216
|
async listActivePlanSummaries(threadId: RecordIdInput): Promise<ListExecutionPlansToolResultData> {
|
|
@@ -245,9 +277,14 @@ class ExecutionPlanService {
|
|
|
245
277
|
async createPlan(params: {
|
|
246
278
|
organizationId: RecordIdInput
|
|
247
279
|
threadId: RecordIdInput
|
|
280
|
+
sourceThreadId?: RecordIdInput
|
|
248
281
|
leadAgentId: string
|
|
282
|
+
createdByAgentId?: string
|
|
283
|
+
requireApproval?: boolean
|
|
249
284
|
input: PlanDraft
|
|
250
285
|
}): Promise<ExecutionPlanToolResultData> {
|
|
286
|
+
const requireApproval =
|
|
287
|
+
params.requireApproval ?? hasCrossThreadSourceContext(params.sourceThreadId, params.threadId)
|
|
251
288
|
const preparedDraft = planBuilderService.prepareDraft(PlanDraftSchema.parse(params.input))
|
|
252
289
|
const validation = planValidatorService.validateDraft(preparedDraft)
|
|
253
290
|
if (validation.blocking.length > 0) {
|
|
@@ -281,7 +318,10 @@ class ExecutionPlanService {
|
|
|
281
318
|
spec,
|
|
282
319
|
organizationId: params.organizationId,
|
|
283
320
|
threadId: params.threadId,
|
|
321
|
+
sourceThreadId: params.sourceThreadId,
|
|
284
322
|
leadAgentId: params.leadAgentId,
|
|
323
|
+
createdByAgentId: params.createdByAgentId,
|
|
324
|
+
requireApproval,
|
|
285
325
|
nodes: compiled.nodes,
|
|
286
326
|
emittedEvents,
|
|
287
327
|
createdEventType: 'plan-created',
|
|
@@ -293,25 +333,13 @@ class ExecutionPlanService {
|
|
|
293
333
|
|
|
294
334
|
await planEventDeliveryService.dispatchEvents(emittedEvents)
|
|
295
335
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const schedule = await planSchedulerService.createSchedule({
|
|
336
|
+
if (!requireApproval) {
|
|
337
|
+
await this.attachPlanScheduleIfNeeded({
|
|
299
338
|
organizationId: params.organizationId,
|
|
300
339
|
threadId: params.threadId,
|
|
301
|
-
planSpecId: specId,
|
|
302
340
|
runId,
|
|
303
|
-
|
|
341
|
+
planSpecId: specId,
|
|
304
342
|
})
|
|
305
|
-
|
|
306
|
-
await databaseService.update(
|
|
307
|
-
TABLES.PLAN_RUN,
|
|
308
|
-
ensureRecordId(runId, TABLES.PLAN_RUN),
|
|
309
|
-
{
|
|
310
|
-
scheduleId: ensureRecordId(schedule.id, TABLES.PLAN_SCHEDULE),
|
|
311
|
-
scheduledAt: schedule.nextFireAt ? toDatabaseDateTime(schedule.nextFireAt) : undefined,
|
|
312
|
-
},
|
|
313
|
-
PlanRunSchema,
|
|
314
|
-
)
|
|
315
343
|
}
|
|
316
344
|
|
|
317
345
|
const plan = await this.finalizePlanSnapshot({ runId, emittedBy: params.leadAgentId })
|
|
@@ -323,7 +351,8 @@ class ExecutionPlanService {
|
|
|
323
351
|
threadId: RecordIdInput
|
|
324
352
|
organizationId: RecordIdInput
|
|
325
353
|
leadAgentId: string
|
|
326
|
-
|
|
354
|
+
createdByAgentId?: string
|
|
355
|
+
input: PlanDraft & { runId: string; reason: string; requireApproval?: boolean }
|
|
327
356
|
}): Promise<ExecutionPlanToolResultData> {
|
|
328
357
|
const activeRun = await planRunService.getRunById(params.input.runId)
|
|
329
358
|
const resolvedThreadId = activeRun.threadId
|
|
@@ -337,7 +366,7 @@ class ExecutionPlanService {
|
|
|
337
366
|
}
|
|
338
367
|
|
|
339
368
|
const activeSpec = await planRunService.getPlanSpecById(activeRun.planSpecId)
|
|
340
|
-
const { runId: _runId, reason: _reason, ...draftInput } = params.input
|
|
369
|
+
const { runId: _runId, reason: _reason, requireApproval: requestedRequireApproval, ...draftInput } = params.input
|
|
341
370
|
const preparedDraft = planBuilderService.prepareDraft(PlanDraftSchema.parse(draftInput))
|
|
342
371
|
const validation = planValidatorService.validateDraft(preparedDraft)
|
|
343
372
|
if (validation.blocking.length > 0) {
|
|
@@ -394,7 +423,11 @@ class ExecutionPlanService {
|
|
|
394
423
|
spec,
|
|
395
424
|
organizationId: params.organizationId,
|
|
396
425
|
threadId: resolvedThreadId,
|
|
426
|
+
sourceThreadId: activeRun.sourceThreadId,
|
|
397
427
|
leadAgentId: params.leadAgentId,
|
|
428
|
+
createdByAgentId: params.createdByAgentId ?? params.leadAgentId,
|
|
429
|
+
requireApproval:
|
|
430
|
+
requestedRequireApproval ?? hasCrossThreadSourceContext(activeRun.sourceThreadId, resolvedThreadId),
|
|
398
431
|
nodes: compiled.nodes,
|
|
399
432
|
emittedEvents,
|
|
400
433
|
runPatch: { replacedRunId: abortedRun.id },
|
|
@@ -410,6 +443,15 @@ class ExecutionPlanService {
|
|
|
410
443
|
|
|
411
444
|
await planEventDeliveryService.dispatchEvents(emittedEvents)
|
|
412
445
|
|
|
446
|
+
if (!(requestedRequireApproval ?? hasCrossThreadSourceContext(activeRun.sourceThreadId, resolvedThreadId))) {
|
|
447
|
+
await this.attachPlanScheduleIfNeeded({
|
|
448
|
+
organizationId: params.organizationId,
|
|
449
|
+
threadId: resolvedThreadId,
|
|
450
|
+
runId,
|
|
451
|
+
planSpecId: specId,
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
|
|
413
455
|
const plan = await this.finalizePlanSnapshot({ runId, emittedBy: params.leadAgentId })
|
|
414
456
|
|
|
415
457
|
return buildExecutionPlanToolResult({
|
|
@@ -482,6 +524,243 @@ class ExecutionPlanService {
|
|
|
482
524
|
})
|
|
483
525
|
}
|
|
484
526
|
|
|
527
|
+
async approvePlan(params: {
|
|
528
|
+
organizationId: RecordIdInput
|
|
529
|
+
threadId: RecordIdInput
|
|
530
|
+
runId: RecordIdInput
|
|
531
|
+
emittedBy: string
|
|
532
|
+
}): Promise<SerializableExecutionPlan> {
|
|
533
|
+
const run = await planRunService.getRunById(params.runId)
|
|
534
|
+
if (
|
|
535
|
+
recordIdToString(run.organizationId, TABLES.ORGANIZATION) !==
|
|
536
|
+
recordIdToString(params.organizationId, TABLES.ORGANIZATION)
|
|
537
|
+
) {
|
|
538
|
+
throw new Error('Plan run belongs to a different organization.')
|
|
539
|
+
}
|
|
540
|
+
if (!isPlanVisibleInThreadContext(params.threadId, run)) {
|
|
541
|
+
throw new Error('Plan run is not available in this thread context.')
|
|
542
|
+
}
|
|
543
|
+
if (run.status !== 'pending-approval') {
|
|
544
|
+
throw new Error('Only pending-approval plans can be approved.')
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const [spec, nodeSpecs, nodeRuns, latestCheckpoint] = await Promise.all([
|
|
548
|
+
planRunService.getPlanSpecById(run.planSpecId),
|
|
549
|
+
planRunService.listNodeSpecs(run.planSpecId),
|
|
550
|
+
planRunService.listNodeRuns(run.id),
|
|
551
|
+
planRunService.getLatestCheckpoint(run.id),
|
|
552
|
+
])
|
|
553
|
+
const emittedEvents: PlanEventRecord[] = []
|
|
554
|
+
|
|
555
|
+
await databaseService.withTransaction(async (tx) => {
|
|
556
|
+
const activatedRun = PlanRunSchema.parse(
|
|
557
|
+
await tx
|
|
558
|
+
.update(ensureRecordId(run.id, TABLES.PLAN_RUN))
|
|
559
|
+
.content(toRunData(run, { status: 'running', startedAt: run.startedAt ?? new Date() }))
|
|
560
|
+
.output('after'),
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
const synced = await planExecutorService.syncRunGraph({
|
|
564
|
+
tx,
|
|
565
|
+
run: activatedRun,
|
|
566
|
+
spec,
|
|
567
|
+
nodeSpecs,
|
|
568
|
+
nodeRuns,
|
|
569
|
+
artifacts: [],
|
|
570
|
+
emittedBy: params.emittedBy,
|
|
571
|
+
capturedEvents: emittedEvents,
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
const checkpoint = PlanCheckpointSchema.parse(
|
|
575
|
+
await tx
|
|
576
|
+
.create(new RecordId(TABLES.PLAN_CHECKPOINT, Bun.randomUUIDv7()))
|
|
577
|
+
.content({
|
|
578
|
+
runId: ensureRecordId(synced.run.id, TABLES.PLAN_RUN),
|
|
579
|
+
sequence: (latestCheckpoint?.sequence ?? 0) + 1,
|
|
580
|
+
runStatus: synced.run.status,
|
|
581
|
+
readyNodeIds: [...synced.run.readyNodeIds],
|
|
582
|
+
activeNodeIds: synced.run.currentNodeId ? [synced.run.currentNodeId] : [],
|
|
583
|
+
artifactIds: [],
|
|
584
|
+
lastCompletedNodeIds: synced.nodeRuns
|
|
585
|
+
.filter((nodeRun) => nodeRun.status === 'completed' || nodeRun.status === 'partial')
|
|
586
|
+
.map((nodeRun) => nodeRun.nodeId),
|
|
587
|
+
snapshot: {
|
|
588
|
+
reason: 'plan-approved',
|
|
589
|
+
currentNodeId: synced.run.currentNodeId,
|
|
590
|
+
waitingNodeId: synced.run.waitingNodeId,
|
|
591
|
+
readyNodeIds: synced.run.readyNodeIds,
|
|
592
|
+
},
|
|
593
|
+
})
|
|
594
|
+
.output('after'),
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
const updatedRun = PlanRunSchema.parse(
|
|
598
|
+
await tx
|
|
599
|
+
.update(ensureRecordId(synced.run.id, TABLES.PLAN_RUN))
|
|
600
|
+
.content(toRunData(synced.run, { lastCheckpointId: checkpoint.id }))
|
|
601
|
+
.output('after'),
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
const approvedEvent = await tx
|
|
605
|
+
.create(new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7()))
|
|
606
|
+
.content({
|
|
607
|
+
planSpecId: ensureRecordId(spec.id, TABLES.PLAN_SPEC),
|
|
608
|
+
runId: ensureRecordId(updatedRun.id, TABLES.PLAN_RUN),
|
|
609
|
+
eventType: 'plan-approved',
|
|
610
|
+
fromStatus: run.status,
|
|
611
|
+
toStatus: updatedRun.status,
|
|
612
|
+
message: `Approved execution plan "${spec.title}".`,
|
|
613
|
+
emittedBy: params.emittedBy,
|
|
614
|
+
detail: { title: spec.title, objective: spec.objective },
|
|
615
|
+
})
|
|
616
|
+
.output('after')
|
|
617
|
+
emittedEvents.push(PlanEventSchema.parse(approvedEvent))
|
|
618
|
+
|
|
619
|
+
const checkpointEvent = await tx
|
|
620
|
+
.create(new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7()))
|
|
621
|
+
.content({
|
|
622
|
+
planSpecId: ensureRecordId(spec.id, TABLES.PLAN_SPEC),
|
|
623
|
+
runId: ensureRecordId(updatedRun.id, TABLES.PLAN_RUN),
|
|
624
|
+
eventType: 'checkpoint-saved',
|
|
625
|
+
message: `Saved checkpoint ${(latestCheckpoint?.sequence ?? 0) + 1}.`,
|
|
626
|
+
emittedBy: 'system',
|
|
627
|
+
detail: { checkpointId: recordIdToString(checkpoint.id, TABLES.PLAN_CHECKPOINT), reason: 'plan-approved' },
|
|
628
|
+
})
|
|
629
|
+
.output('after')
|
|
630
|
+
emittedEvents.push(PlanEventSchema.parse(checkpointEvent))
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
await planEventDeliveryService.dispatchEvents(emittedEvents)
|
|
634
|
+
await this.attachPlanScheduleIfNeeded({
|
|
635
|
+
organizationId: run.organizationId,
|
|
636
|
+
threadId: run.threadId,
|
|
637
|
+
runId: run.id,
|
|
638
|
+
planSpecId: run.planSpecId,
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
return await this.finalizePlanSnapshot({ runId: run.id, emittedBy: params.emittedBy })
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async rejectPlan(params: {
|
|
645
|
+
organizationId: RecordIdInput
|
|
646
|
+
threadId: RecordIdInput
|
|
647
|
+
runId: RecordIdInput
|
|
648
|
+
emittedBy: string
|
|
649
|
+
reason?: string
|
|
650
|
+
resolution?: 'rejected' | 'changes-requested'
|
|
651
|
+
}): Promise<SerializableExecutionPlan> {
|
|
652
|
+
const resolution = params.resolution ?? 'rejected'
|
|
653
|
+
const eventType = resolution === 'changes-requested' ? 'plan-changes-requested' : 'plan-rejected'
|
|
654
|
+
const run = await planRunService.getRunById(params.runId)
|
|
655
|
+
if (
|
|
656
|
+
recordIdToString(run.organizationId, TABLES.ORGANIZATION) !==
|
|
657
|
+
recordIdToString(params.organizationId, TABLES.ORGANIZATION)
|
|
658
|
+
) {
|
|
659
|
+
throw new Error('Plan run belongs to a different organization.')
|
|
660
|
+
}
|
|
661
|
+
if (!isPlanVisibleInThreadContext(params.threadId, run)) {
|
|
662
|
+
throw new Error('Plan run is not available in this thread context.')
|
|
663
|
+
}
|
|
664
|
+
if (run.status !== 'pending-approval') {
|
|
665
|
+
throw new Error('Only pending-approval plans can be rejected.')
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const [spec, nodeRuns, latestCheckpoint] = await Promise.all([
|
|
669
|
+
planRunService.getPlanSpecById(run.planSpecId),
|
|
670
|
+
planRunService.listNodeRuns(run.id),
|
|
671
|
+
planRunService.getLatestCheckpoint(run.id),
|
|
672
|
+
])
|
|
673
|
+
const emittedEvents: PlanEventRecord[] = []
|
|
674
|
+
const checkpointReason = resolution === 'changes-requested' ? 'plan-changes-requested' : 'plan-rejected'
|
|
675
|
+
|
|
676
|
+
await databaseService.withTransaction(async (tx) => {
|
|
677
|
+
const rejectedRun = PlanRunSchema.parse(
|
|
678
|
+
await tx
|
|
679
|
+
.update(ensureRecordId(run.id, TABLES.PLAN_RUN))
|
|
680
|
+
.content(
|
|
681
|
+
toRunData(run, {
|
|
682
|
+
status: 'aborted',
|
|
683
|
+
currentNodeId: null,
|
|
684
|
+
waitingNodeId: null,
|
|
685
|
+
readyNodeIds: [],
|
|
686
|
+
completedAt: new Date(),
|
|
687
|
+
}),
|
|
688
|
+
)
|
|
689
|
+
.output('after'),
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
const event = await tx
|
|
693
|
+
.create(new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7()))
|
|
694
|
+
.content({
|
|
695
|
+
planSpecId: ensureRecordId(spec.id, TABLES.PLAN_SPEC),
|
|
696
|
+
runId: ensureRecordId(rejectedRun.id, TABLES.PLAN_RUN),
|
|
697
|
+
eventType,
|
|
698
|
+
fromStatus: run.status,
|
|
699
|
+
toStatus: rejectedRun.status,
|
|
700
|
+
message:
|
|
701
|
+
resolution === 'changes-requested'
|
|
702
|
+
? `Requested changes for execution plan "${spec.title}".`
|
|
703
|
+
: `Rejected execution plan "${spec.title}".`,
|
|
704
|
+
emittedBy: params.emittedBy,
|
|
705
|
+
detail: { resolution, ...(params.reason ? { reason: params.reason } : {}) },
|
|
706
|
+
})
|
|
707
|
+
.output('after')
|
|
708
|
+
emittedEvents.push(PlanEventSchema.parse(event))
|
|
709
|
+
|
|
710
|
+
const checkpoint = PlanCheckpointSchema.parse(
|
|
711
|
+
await tx
|
|
712
|
+
.create(new RecordId(TABLES.PLAN_CHECKPOINT, Bun.randomUUIDv7()))
|
|
713
|
+
.content({
|
|
714
|
+
runId: ensureRecordId(rejectedRun.id, TABLES.PLAN_RUN),
|
|
715
|
+
sequence: (latestCheckpoint?.sequence ?? 0) + 1,
|
|
716
|
+
runStatus: rejectedRun.status,
|
|
717
|
+
readyNodeIds: [],
|
|
718
|
+
activeNodeIds: [],
|
|
719
|
+
artifactIds: [],
|
|
720
|
+
lastCompletedNodeIds: nodeRuns
|
|
721
|
+
.filter((nodeRun) => nodeRun.status === 'completed' || nodeRun.status === 'partial')
|
|
722
|
+
.map((nodeRun) => nodeRun.nodeId),
|
|
723
|
+
snapshot: {
|
|
724
|
+
reason: checkpointReason,
|
|
725
|
+
resolution,
|
|
726
|
+
currentNodeId: null,
|
|
727
|
+
waitingNodeId: null,
|
|
728
|
+
readyNodeIds: [],
|
|
729
|
+
},
|
|
730
|
+
})
|
|
731
|
+
.output('after'),
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
const updatedRun = PlanRunSchema.parse(
|
|
735
|
+
await tx
|
|
736
|
+
.update(ensureRecordId(rejectedRun.id, TABLES.PLAN_RUN))
|
|
737
|
+
.content(toRunData(rejectedRun, { lastCheckpointId: checkpoint.id }))
|
|
738
|
+
.output('after'),
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
const checkpointEvent = await tx
|
|
742
|
+
.create(new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7()))
|
|
743
|
+
.content({
|
|
744
|
+
planSpecId: ensureRecordId(spec.id, TABLES.PLAN_SPEC),
|
|
745
|
+
runId: ensureRecordId(updatedRun.id, TABLES.PLAN_RUN),
|
|
746
|
+
eventType: 'checkpoint-saved',
|
|
747
|
+
message: `Saved checkpoint ${(latestCheckpoint?.sequence ?? 0) + 1}.`,
|
|
748
|
+
emittedBy: 'system',
|
|
749
|
+
detail: {
|
|
750
|
+
checkpointId: recordIdToString(checkpoint.id, TABLES.PLAN_CHECKPOINT),
|
|
751
|
+
reason: checkpointReason,
|
|
752
|
+
resolution,
|
|
753
|
+
},
|
|
754
|
+
})
|
|
755
|
+
.output('after')
|
|
756
|
+
emittedEvents.push(PlanEventSchema.parse(checkpointEvent))
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
await planEventDeliveryService.dispatchEvents(emittedEvents)
|
|
760
|
+
|
|
761
|
+
return await planRunService.toSerializablePlan(await planRunService.getRunById(run.id))
|
|
762
|
+
}
|
|
763
|
+
|
|
485
764
|
async applyApprovalResponseFromMessages(params: {
|
|
486
765
|
threadId: RecordIdInput
|
|
487
766
|
approvalMessages: ChatMessage[]
|
|
@@ -554,6 +833,23 @@ class ExecutionPlanService {
|
|
|
554
833
|
return ownershipDispatcherService.dispatchRunToStableBoundary({ runId: run.id, emittedBy: params.respondedBy })
|
|
555
834
|
}
|
|
556
835
|
|
|
836
|
+
private async serializeRuns(
|
|
837
|
+
runs: PlanRunRecord[],
|
|
838
|
+
options?: Partial<ExecutionPlanQueryArgs>,
|
|
839
|
+
): Promise<SerializableExecutionPlan[]> {
|
|
840
|
+
return await Promise.all(
|
|
841
|
+
runs.map((run) =>
|
|
842
|
+
planRunService.toSerializablePlan(run, {
|
|
843
|
+
includeEvents: options?.includeEvents,
|
|
844
|
+
includeArtifacts: options?.includeArtifacts,
|
|
845
|
+
includeApprovals: options?.includeApprovals,
|
|
846
|
+
includeCheckpoints: options?.includeCheckpoints,
|
|
847
|
+
includeValidationIssues: options?.includeValidationIssues,
|
|
848
|
+
}),
|
|
849
|
+
),
|
|
850
|
+
)
|
|
851
|
+
}
|
|
852
|
+
|
|
557
853
|
private async assertDispatchExecutors(preparedDraft: Parameters<typeof planValidatorService.validateDraft>[0]) {
|
|
558
854
|
const issues = ownershipDispatcherService.validateDraftExecutors(preparedDraft)
|
|
559
855
|
if (issues.length > 0) {
|
|
@@ -568,6 +864,41 @@ class ExecutionPlanService {
|
|
|
568
864
|
return ownershipDispatcherService.dispatchRunToStableBoundary({ runId: params.runId, emittedBy: params.emittedBy })
|
|
569
865
|
}
|
|
570
866
|
|
|
867
|
+
private async attachPlanScheduleIfNeeded(params: {
|
|
868
|
+
organizationId: RecordIdInput
|
|
869
|
+
threadId: RecordIdInput
|
|
870
|
+
runId: RecordIdInput
|
|
871
|
+
planSpecId: RecordIdInput
|
|
872
|
+
}): Promise<PlanRunRecord> {
|
|
873
|
+
const [run, spec] = await Promise.all([
|
|
874
|
+
planRunService.getRunById(params.runId),
|
|
875
|
+
planRunService.getPlanSpecById(params.planSpecId),
|
|
876
|
+
])
|
|
877
|
+
if (!spec.schedule || run.scheduleId) {
|
|
878
|
+
return run
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const schedule = await planSchedulerService.createSchedule({
|
|
882
|
+
organizationId: params.organizationId,
|
|
883
|
+
threadId: params.threadId,
|
|
884
|
+
planSpecId: params.planSpecId,
|
|
885
|
+
runId: params.runId,
|
|
886
|
+
scheduleSpec: spec.schedule,
|
|
887
|
+
})
|
|
888
|
+
|
|
889
|
+
const updatedRun = await databaseService.update(
|
|
890
|
+
TABLES.PLAN_RUN,
|
|
891
|
+
ensureRecordId(params.runId, TABLES.PLAN_RUN),
|
|
892
|
+
toRunData(run, {
|
|
893
|
+
scheduleId: schedule.id,
|
|
894
|
+
scheduledAt: schedule.nextFireAt ? toDatabaseDateTime(schedule.nextFireAt) : undefined,
|
|
895
|
+
}),
|
|
896
|
+
PlanRunSchema,
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
return updatedRun ?? run
|
|
900
|
+
}
|
|
901
|
+
|
|
571
902
|
private async createNodeSpecs(
|
|
572
903
|
tx: DatabaseTransaction,
|
|
573
904
|
planSpecId: RecordIdInput,
|
|
@@ -657,7 +988,10 @@ class ExecutionPlanService {
|
|
|
657
988
|
spec: PlanSpecRecord
|
|
658
989
|
organizationId: RecordIdInput
|
|
659
990
|
threadId: RecordIdInput
|
|
991
|
+
sourceThreadId?: RecordIdInput
|
|
660
992
|
leadAgentId: string
|
|
993
|
+
createdByAgentId?: string
|
|
994
|
+
requireApproval: boolean
|
|
661
995
|
nodes: CompiledPlanNode[]
|
|
662
996
|
emittedEvents: PlanEventRecord[]
|
|
663
997
|
createdEventType: 'plan-created' | 'plan-replaced'
|
|
@@ -665,7 +999,7 @@ class ExecutionPlanService {
|
|
|
665
999
|
createdEventDetail: Record<string, unknown>
|
|
666
1000
|
checkpointReason: 'plan-created' | 'plan-replaced'
|
|
667
1001
|
runPatch?: { replacedRunId?: RecordIdInput }
|
|
668
|
-
}): Promise<
|
|
1002
|
+
}): Promise<PlanRunRecord> {
|
|
669
1003
|
const nodeSpecs = await this.createNodeSpecs(params.tx, params.spec.id, params.nodes)
|
|
670
1004
|
const run = PlanRunSchema.parse(
|
|
671
1005
|
await params.tx
|
|
@@ -674,29 +1008,33 @@ class ExecutionPlanService {
|
|
|
674
1008
|
planSpecId: ensureRecordId(params.spec.id, TABLES.PLAN_SPEC),
|
|
675
1009
|
organizationId: ensureRecordId(params.organizationId, TABLES.ORGANIZATION),
|
|
676
1010
|
threadId: ensureRecordId(params.threadId, TABLES.THREAD),
|
|
1011
|
+
...(params.sourceThreadId ? { sourceThreadId: ensureRecordId(params.sourceThreadId, TABLES.THREAD) } : {}),
|
|
677
1012
|
leadAgentId: params.leadAgentId,
|
|
678
|
-
|
|
1013
|
+
...(params.createdByAgentId ? { createdByAgentId: params.createdByAgentId } : {}),
|
|
1014
|
+
status: params.requireApproval ? 'pending-approval' : 'running',
|
|
679
1015
|
readyNodeIds: [],
|
|
680
1016
|
failureCount: 0,
|
|
681
1017
|
...(params.runPatch?.replacedRunId
|
|
682
1018
|
? { replacedRunId: ensureRecordId(params.runPatch.replacedRunId, TABLES.PLAN_RUN) }
|
|
683
1019
|
: {}),
|
|
684
|
-
startedAt: new Date(),
|
|
1020
|
+
...(params.requireApproval ? {} : { startedAt: new Date() }),
|
|
685
1021
|
})
|
|
686
1022
|
.output('after'),
|
|
687
1023
|
)
|
|
688
1024
|
|
|
689
1025
|
const nodeRuns = await this.createNodeRuns(params.tx, run.id, params.spec.id, nodeSpecs)
|
|
690
|
-
const synced =
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
1026
|
+
const synced = params.requireApproval
|
|
1027
|
+
? { run, nodeRuns }
|
|
1028
|
+
: await planExecutorService.syncRunGraph({
|
|
1029
|
+
tx: params.tx,
|
|
1030
|
+
run,
|
|
1031
|
+
spec: params.spec,
|
|
1032
|
+
nodeSpecs,
|
|
1033
|
+
nodeRuns,
|
|
1034
|
+
artifacts: [],
|
|
1035
|
+
emittedBy: params.leadAgentId,
|
|
1036
|
+
capturedEvents: params.emittedEvents,
|
|
1037
|
+
})
|
|
700
1038
|
|
|
701
1039
|
const event = await params.tx
|
|
702
1040
|
.create(new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7()))
|
|
@@ -711,6 +1049,22 @@ class ExecutionPlanService {
|
|
|
711
1049
|
.output('after')
|
|
712
1050
|
params.emittedEvents.push(PlanEventSchema.parse(event))
|
|
713
1051
|
|
|
1052
|
+
if (params.requireApproval) {
|
|
1053
|
+
const pendingApprovalEvent = await params.tx
|
|
1054
|
+
.create(new RecordId(TABLES.PLAN_EVENT, Bun.randomUUIDv7()))
|
|
1055
|
+
.content({
|
|
1056
|
+
planSpecId: ensureRecordId(params.spec.id, TABLES.PLAN_SPEC),
|
|
1057
|
+
runId: ensureRecordId(synced.run.id, TABLES.PLAN_RUN),
|
|
1058
|
+
eventType: 'plan-pending-approval',
|
|
1059
|
+
toStatus: synced.run.status,
|
|
1060
|
+
message: `Execution plan "${params.spec.title}" is pending approval.`,
|
|
1061
|
+
emittedBy: params.leadAgentId,
|
|
1062
|
+
detail: { ...params.createdEventDetail, nodeCount: nodeSpecs.length },
|
|
1063
|
+
})
|
|
1064
|
+
.output('after')
|
|
1065
|
+
params.emittedEvents.push(PlanEventSchema.parse(pendingApprovalEvent))
|
|
1066
|
+
}
|
|
1067
|
+
|
|
714
1068
|
const checkpoint = PlanCheckpointSchema.parse(
|
|
715
1069
|
await params.tx
|
|
716
1070
|
.create(new RecordId(TABLES.PLAN_CHECKPOINT, Bun.randomUUIDv7()))
|
|
@@ -756,6 +1110,8 @@ class ExecutionPlanService {
|
|
|
756
1110
|
})
|
|
757
1111
|
.output('after')
|
|
758
1112
|
params.emittedEvents.push(PlanEventSchema.parse(checkpointEvent))
|
|
1113
|
+
|
|
1114
|
+
return updatedRun
|
|
759
1115
|
}
|
|
760
1116
|
}
|
|
761
1117
|
|
|
@@ -16,7 +16,7 @@ function formatDispatchError(error: unknown): string {
|
|
|
16
16
|
return error instanceof Error ? error.message : String(error)
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
const STABLE_RUN_STATUSES = new Set(['awaiting-human', 'blocked', 'failed', 'completed', 'aborted'])
|
|
19
|
+
const STABLE_RUN_STATUSES = new Set(['pending-approval', 'awaiting-human', 'blocked', 'failed', 'completed', 'aborted'])
|
|
20
20
|
|
|
21
21
|
class GlobalOrchestratorService {
|
|
22
22
|
detectConvergence(params: {
|
|
@@ -34,7 +34,7 @@ import { systemExecutorService } from './system-executor.service'
|
|
|
34
34
|
import { ThreadSchema } from './thread.types'
|
|
35
35
|
import { userService } from './user.service'
|
|
36
36
|
|
|
37
|
-
const STABLE_RUN_STATUSES = new Set(['awaiting-human', 'blocked', 'failed', 'completed', 'aborted'])
|
|
37
|
+
const STABLE_RUN_STATUSES = new Set(['pending-approval', 'awaiting-human', 'blocked', 'failed', 'completed', 'aborted'])
|
|
38
38
|
const MAX_DISPATCH_ITERATIONS = 64
|
|
39
39
|
|
|
40
40
|
function toPlanNodeSpec(nodeSpec: PlanNodeSpecRecord): PlanNodeSpec {
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ExecutionPlanToolPlanSummary,
|
|
3
|
+
ExecutionPlanToolResultData,
|
|
4
|
+
PlanRunRecord,
|
|
5
|
+
SerializableExecutionPlan,
|
|
6
|
+
} from '@lota-sdk/shared'
|
|
2
7
|
|
|
3
8
|
import type { RecordIdInput } from '../db/record-id'
|
|
4
9
|
import { ensureRecordId } from '../db/record-id'
|
|
@@ -7,12 +12,25 @@ import { toDatabaseDateTime } from '../utils/date-time'
|
|
|
7
12
|
|
|
8
13
|
export type PlanRunUpdate = Omit<
|
|
9
14
|
Partial<PlanRunRecord>,
|
|
10
|
-
|
|
15
|
+
| 'sourceThreadId'
|
|
16
|
+
| 'createdByAgentId'
|
|
17
|
+
| 'currentNodeId'
|
|
18
|
+
| 'waitingNodeId'
|
|
19
|
+
| 'replacedRunId'
|
|
20
|
+
| 'lastCheckpointId'
|
|
21
|
+
| 'scheduledAt'
|
|
22
|
+
| 'scheduleId'
|
|
23
|
+
| 'startedAt'
|
|
24
|
+
| 'completedAt'
|
|
11
25
|
> & {
|
|
26
|
+
sourceThreadId?: RecordIdInput | null
|
|
27
|
+
createdByAgentId?: string | null
|
|
12
28
|
currentNodeId?: string | null
|
|
13
29
|
waitingNodeId?: string | null
|
|
14
30
|
replacedRunId?: RecordIdInput | null
|
|
15
31
|
lastCheckpointId?: RecordIdInput | null
|
|
32
|
+
scheduledAt?: string | Date | null
|
|
33
|
+
scheduleId?: RecordIdInput | null
|
|
16
34
|
startedAt?: string | Date | null
|
|
17
35
|
completedAt?: string | Date | null
|
|
18
36
|
}
|
|
@@ -22,7 +40,21 @@ export function toRunData(run: PlanRunRecord, patch: PlanRunUpdate) {
|
|
|
22
40
|
planSpecId: ensureRecordId(run.planSpecId, TABLES.PLAN_SPEC),
|
|
23
41
|
organizationId: ensureRecordId(run.organizationId, TABLES.ORGANIZATION),
|
|
24
42
|
threadId: ensureRecordId(run.threadId, TABLES.THREAD),
|
|
43
|
+
...(patch.sourceThreadId === null
|
|
44
|
+
? {}
|
|
45
|
+
: patch.sourceThreadId !== undefined
|
|
46
|
+
? { sourceThreadId: ensureRecordId(patch.sourceThreadId, TABLES.THREAD) }
|
|
47
|
+
: run.sourceThreadId
|
|
48
|
+
? { sourceThreadId: ensureRecordId(run.sourceThreadId, TABLES.THREAD) }
|
|
49
|
+
: {}),
|
|
25
50
|
leadAgentId: patch.leadAgentId ?? run.leadAgentId,
|
|
51
|
+
...(patch.createdByAgentId === null
|
|
52
|
+
? {}
|
|
53
|
+
: patch.createdByAgentId !== undefined
|
|
54
|
+
? { createdByAgentId: patch.createdByAgentId }
|
|
55
|
+
: run.createdByAgentId
|
|
56
|
+
? { createdByAgentId: run.createdByAgentId }
|
|
57
|
+
: {}),
|
|
26
58
|
status: patch.status ?? run.status,
|
|
27
59
|
...(patch.currentNodeId === null
|
|
28
60
|
? {}
|
|
@@ -54,6 +86,20 @@ export function toRunData(run: PlanRunRecord, patch: PlanRunUpdate) {
|
|
|
54
86
|
: run.lastCheckpointId
|
|
55
87
|
? { lastCheckpointId: ensureRecordId(run.lastCheckpointId, TABLES.PLAN_CHECKPOINT) }
|
|
56
88
|
: {}),
|
|
89
|
+
...(patch.scheduledAt === null
|
|
90
|
+
? {}
|
|
91
|
+
: patch.scheduledAt !== undefined
|
|
92
|
+
? { scheduledAt: toDatabaseDateTime(patch.scheduledAt) }
|
|
93
|
+
: run.scheduledAt
|
|
94
|
+
? { scheduledAt: toDatabaseDateTime(run.scheduledAt) }
|
|
95
|
+
: {}),
|
|
96
|
+
...(patch.scheduleId === null
|
|
97
|
+
? {}
|
|
98
|
+
: patch.scheduleId
|
|
99
|
+
? { scheduleId: ensureRecordId(patch.scheduleId, TABLES.PLAN_SCHEDULE) }
|
|
100
|
+
: run.scheduleId
|
|
101
|
+
? { scheduleId: ensureRecordId(run.scheduleId, TABLES.PLAN_SCHEDULE) }
|
|
102
|
+
: {}),
|
|
57
103
|
...(patch.startedAt === null
|
|
58
104
|
? {}
|
|
59
105
|
: patch.startedAt !== undefined
|
|
@@ -71,13 +117,17 @@ export function toRunData(run: PlanRunRecord, patch: PlanRunUpdate) {
|
|
|
71
117
|
}
|
|
72
118
|
}
|
|
73
119
|
|
|
74
|
-
function
|
|
120
|
+
export function toToolPlanSummary(plan: SerializableExecutionPlan): ExecutionPlanToolPlanSummary {
|
|
75
121
|
const completed = plan.progress.completed + plan.progress.partial
|
|
76
122
|
return {
|
|
77
123
|
runId: plan.runId,
|
|
124
|
+
threadId: plan.threadId,
|
|
78
125
|
title: plan.title,
|
|
79
126
|
objective: plan.objective,
|
|
80
127
|
status: plan.status,
|
|
128
|
+
leadAgentId: plan.leadAgentId,
|
|
129
|
+
...(plan.sourceThreadId ? { sourceThreadId: plan.sourceThreadId } : {}),
|
|
130
|
+
...(plan.createdByAgentId ? { createdByAgentId: plan.createdByAgentId } : {}),
|
|
81
131
|
progress: { completed, total: plan.progress.total },
|
|
82
132
|
nodes: plan.nodes.map((node) => ({
|
|
83
133
|
id: node.id,
|
|
@@ -85,7 +135,23 @@ function toSlimPlanSummary(plan: SerializableExecutionPlan): NonNullable<Executi
|
|
|
85
135
|
type: node.type,
|
|
86
136
|
status: node.status,
|
|
87
137
|
ownerRef: node.owner.ref,
|
|
138
|
+
objective: node.objective,
|
|
139
|
+
ownerType: node.owner.executorType,
|
|
140
|
+
artifactCount: plan.artifacts.filter((artifact) => artifact.nodeId === node.id).length,
|
|
141
|
+
approvalId:
|
|
142
|
+
plan.approvals.find((approval) => approval.nodeId === node.id && approval.status === 'pending')?.id ?? null,
|
|
143
|
+
approvalStatus:
|
|
144
|
+
plan.approvals.find((approval) => approval.nodeId === node.id && approval.status === 'pending')?.status ?? null,
|
|
145
|
+
blockedReason: node.blockedReason ?? null,
|
|
146
|
+
latestNotes: node.latestNotes ?? null,
|
|
147
|
+
startedAt: node.startedAt ?? null,
|
|
148
|
+
completedAt: node.completedAt ?? null,
|
|
149
|
+
readyAt: node.readyAt ?? null,
|
|
150
|
+
deliverableNames: node.deliverables.map((deliverable) => deliverable.name),
|
|
151
|
+
upstreamNodeIds: [...node.upstreamNodeIds],
|
|
152
|
+
downstreamNodeIds: [...node.downstreamNodeIds],
|
|
88
153
|
})),
|
|
154
|
+
edges: [...plan.edges],
|
|
89
155
|
activeNodeIds: plan.activeNodeIds,
|
|
90
156
|
readyNodeIds: plan.readyNodeIds,
|
|
91
157
|
}
|
|
@@ -96,7 +162,7 @@ export function buildExecutionPlanToolResult(params: {
|
|
|
96
162
|
plan: SerializableExecutionPlan | null
|
|
97
163
|
message: string
|
|
98
164
|
}): ExecutionPlanToolResultData {
|
|
99
|
-
const slim = params.plan ?
|
|
165
|
+
const slim = params.plan ? toToolPlanSummary(params.plan) : null
|
|
100
166
|
return {
|
|
101
167
|
action: params.action,
|
|
102
168
|
message: params.message,
|
|
@@ -37,7 +37,7 @@ import { databaseService } from '../db/service'
|
|
|
37
37
|
import { TABLES } from '../db/tables'
|
|
38
38
|
import { toIsoDateTimeString, toOptionalIsoDateTimeString } from '../utils/date-time'
|
|
39
39
|
|
|
40
|
-
const ACTIVE_RUN_STATUSES = new Set(['running', 'awaiting-human', 'blocked'])
|
|
40
|
+
const ACTIVE_RUN_STATUSES = new Set(['pending-approval', 'running', 'awaiting-human', 'blocked'])
|
|
41
41
|
|
|
42
42
|
function buildProgress(nodeRuns: PlanNodeRunRecord[]) {
|
|
43
43
|
const counts = nodeRuns.reduce(
|
|
@@ -239,6 +239,31 @@ class PlanRunService {
|
|
|
239
239
|
return runs.filter((run) => ACTIVE_RUN_STATUSES.has(run.status))
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
async getRunsCreatedInContext(params: {
|
|
243
|
+
organizationId: RecordIdInput
|
|
244
|
+
sourceThreadId?: RecordIdInput
|
|
245
|
+
createdByAgentId?: string
|
|
246
|
+
statuses?: ReadonlyArray<PlanRunRecord['status']>
|
|
247
|
+
}): Promise<PlanRunRecord[]> {
|
|
248
|
+
const filter: Record<string, unknown> = {
|
|
249
|
+
organizationId: ensureRecordId(params.organizationId, TABLES.ORGANIZATION),
|
|
250
|
+
}
|
|
251
|
+
if (params.sourceThreadId) {
|
|
252
|
+
filter.sourceThreadId = ensureRecordId(params.sourceThreadId, TABLES.THREAD)
|
|
253
|
+
}
|
|
254
|
+
if (params.createdByAgentId) {
|
|
255
|
+
filter.createdByAgentId = params.createdByAgentId
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const runs = await databaseService.findMany(TABLES.PLAN_RUN, filter, PlanRunSchema, {
|
|
259
|
+
orderBy: 'updatedAt',
|
|
260
|
+
orderDir: 'DESC',
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
const statuses = params.statuses ? new Set(params.statuses) : ACTIVE_RUN_STATUSES
|
|
264
|
+
return runs.filter((run) => statuses.has(run.status))
|
|
265
|
+
}
|
|
266
|
+
|
|
242
267
|
async listNodeRuns(runId: RecordIdInput): Promise<PlanNodeRunRecord[]> {
|
|
243
268
|
return databaseService.findMany(
|
|
244
269
|
TABLES.PLAN_NODE_RUN,
|
|
@@ -435,12 +460,14 @@ class PlanRunService {
|
|
|
435
460
|
specId: recordIdToString(spec.id, TABLES.PLAN_SPEC),
|
|
436
461
|
runId: recordIdToString(run.id, TABLES.PLAN_RUN),
|
|
437
462
|
threadId: recordIdToString(run.threadId, TABLES.THREAD),
|
|
463
|
+
sourceThreadId: run.sourceThreadId ? recordIdToString(run.sourceThreadId, TABLES.THREAD) : undefined,
|
|
438
464
|
organizationId: recordIdToString(run.organizationId, TABLES.ORGANIZATION),
|
|
439
465
|
title: spec.title,
|
|
440
466
|
objective: spec.objective,
|
|
441
467
|
version: spec.version,
|
|
442
468
|
status: run.status,
|
|
443
469
|
leadAgentId: run.leadAgentId,
|
|
470
|
+
createdByAgentId: run.createdByAgentId,
|
|
444
471
|
defaultExecutionVisibility: spec.defaultExecutionVisibility,
|
|
445
472
|
executionMode: spec.executionMode,
|
|
446
473
|
schemaRegistry: slim ? {} : structuredClone(spec.schemaRegistry),
|
|
@@ -143,7 +143,10 @@ class PlanTemplateService {
|
|
|
143
143
|
templateId: RecordIdInput
|
|
144
144
|
organizationId: RecordIdInput
|
|
145
145
|
threadId: RecordIdInput
|
|
146
|
+
sourceThreadId?: RecordIdInput
|
|
146
147
|
leadAgentId: string
|
|
148
|
+
createdByAgentId?: string
|
|
149
|
+
requireApproval?: boolean
|
|
147
150
|
overrides?: Partial<PlanDraft>
|
|
148
151
|
carryForwardArtifacts?: PlanArtifactRecord[]
|
|
149
152
|
}): Promise<ExecutionPlanToolResultData> {
|
|
@@ -162,7 +165,10 @@ class PlanTemplateService {
|
|
|
162
165
|
return executionPlanService.createPlan({
|
|
163
166
|
organizationId: params.organizationId,
|
|
164
167
|
threadId: params.threadId,
|
|
168
|
+
sourceThreadId: params.sourceThreadId,
|
|
165
169
|
leadAgentId: params.leadAgentId,
|
|
170
|
+
createdByAgentId: params.createdByAgentId,
|
|
171
|
+
requireApproval: params.requireApproval,
|
|
166
172
|
input: draft,
|
|
167
173
|
})
|
|
168
174
|
}
|
|
@@ -56,10 +56,16 @@ export function createExecutionPlanTool(params: {
|
|
|
56
56
|
case 'create': {
|
|
57
57
|
const draft = extractAgentPlanDraft(parsed)
|
|
58
58
|
params.validateInlinePlan?.(draft)
|
|
59
|
+
const targetThreadId = parsed.targetThreadId ?? params.threadId
|
|
60
|
+
const isCrossThreadTarget =
|
|
61
|
+
recordIdToString(targetThreadId, TABLES.THREAD) !== recordIdToString(params.threadId, TABLES.THREAD)
|
|
59
62
|
result = await resolvedEpService.createPlan({
|
|
60
63
|
organizationId: params.orgId,
|
|
61
|
-
threadId:
|
|
64
|
+
threadId: targetThreadId,
|
|
65
|
+
...(isCrossThreadTarget ? { sourceThreadId: params.threadId } : {}),
|
|
62
66
|
leadAgentId: params.agentId,
|
|
67
|
+
createdByAgentId: params.agentId,
|
|
68
|
+
requireApproval: parsed.requireApproval ?? isCrossThreadTarget,
|
|
63
69
|
input: draft,
|
|
64
70
|
})
|
|
65
71
|
break
|
|
@@ -101,11 +107,15 @@ export function createExecutionPlanTool(params: {
|
|
|
101
107
|
const created = await resolvedEpService.createPlan({
|
|
102
108
|
organizationId: params.orgId,
|
|
103
109
|
threadId: targetThread.id,
|
|
110
|
+
sourceThreadId: params.threadId,
|
|
104
111
|
leadAgentId: params.agentId,
|
|
112
|
+
createdByAgentId: params.agentId,
|
|
113
|
+
requireApproval: parsed.requireApproval ?? true,
|
|
105
114
|
input: draft,
|
|
106
115
|
})
|
|
107
116
|
result = {
|
|
108
117
|
...created,
|
|
118
|
+
runId: created.plan?.runId ?? '',
|
|
109
119
|
threadId: targetThread.id,
|
|
110
120
|
threadTitle: targetThread.title,
|
|
111
121
|
createdThread,
|
|
@@ -125,7 +135,8 @@ export function createExecutionPlanTool(params: {
|
|
|
125
135
|
organizationId: params.orgId,
|
|
126
136
|
threadId: params.threadId,
|
|
127
137
|
leadAgentId: params.agentId,
|
|
128
|
-
|
|
138
|
+
createdByAgentId: params.agentId,
|
|
139
|
+
input: { runId: parsed.runId, reason: parsed.reason, requireApproval: parsed.requireApproval, ...draft },
|
|
129
140
|
})
|
|
130
141
|
break
|
|
131
142
|
}
|
package/src/tools/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from './execution-plan.tool'
|
|
2
2
|
export * from './fetch-webpage.tool'
|
|
3
3
|
export * from './memory-block.tool'
|
|
4
|
+
export * from './plan-approval.tool'
|
|
4
5
|
export * from './read-file-parts.tool'
|
|
5
6
|
export * from './remember-memory.tool'
|
|
6
7
|
export * from './research-topic.tool'
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { PlanApprovalArgsSchema, PlanApprovalToolResultDataSchema } from '@lota-sdk/shared'
|
|
2
|
+
import { tool } from 'ai'
|
|
3
|
+
|
|
4
|
+
import type { RecordIdRef } from '../db/record-id'
|
|
5
|
+
import { executionPlanService } from '../services/execution-plan.service'
|
|
6
|
+
import { toToolPlanSummary } from '../services/plan-run-data'
|
|
7
|
+
|
|
8
|
+
type PlanApprovalExecutionPlanService = Pick<typeof executionPlanService, 'approvePlan' | 'rejectPlan'>
|
|
9
|
+
|
|
10
|
+
export function createPlanApprovalTool(params: {
|
|
11
|
+
orgId: RecordIdRef
|
|
12
|
+
threadId: RecordIdRef
|
|
13
|
+
actorId: string
|
|
14
|
+
executionPlanService?: PlanApprovalExecutionPlanService
|
|
15
|
+
}) {
|
|
16
|
+
const resolvedExecutionPlanService = params.executionPlanService ?? executionPlanService
|
|
17
|
+
|
|
18
|
+
return tool({
|
|
19
|
+
description:
|
|
20
|
+
'Approve, reject, or request changes to a plan that is pending approval. Use approve to start execution, reject to abort it, or modify to request a revised plan.',
|
|
21
|
+
inputSchema: PlanApprovalArgsSchema,
|
|
22
|
+
outputSchema: PlanApprovalToolResultDataSchema,
|
|
23
|
+
execute: async (input) => {
|
|
24
|
+
switch (input.action) {
|
|
25
|
+
case 'approve': {
|
|
26
|
+
const plan = await resolvedExecutionPlanService.approvePlan({
|
|
27
|
+
organizationId: params.orgId,
|
|
28
|
+
threadId: params.threadId,
|
|
29
|
+
runId: input.runId,
|
|
30
|
+
emittedBy: params.actorId,
|
|
31
|
+
})
|
|
32
|
+
const toolPlan = toToolPlanSummary(plan)
|
|
33
|
+
return {
|
|
34
|
+
action: 'approved',
|
|
35
|
+
message: `Approved execution plan "${plan.title}" and started it.`,
|
|
36
|
+
hasPlan: true,
|
|
37
|
+
status: toolPlan.status,
|
|
38
|
+
plan: toolPlan,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
case 'reject':
|
|
42
|
+
case 'modify': {
|
|
43
|
+
const plan = await resolvedExecutionPlanService.rejectPlan({
|
|
44
|
+
organizationId: params.orgId,
|
|
45
|
+
threadId: params.threadId,
|
|
46
|
+
runId: input.runId,
|
|
47
|
+
emittedBy: params.actorId,
|
|
48
|
+
reason: input.reason,
|
|
49
|
+
resolution: input.action === 'modify' ? 'changes-requested' : 'rejected',
|
|
50
|
+
})
|
|
51
|
+
const toolPlan = toToolPlanSummary(plan)
|
|
52
|
+
return {
|
|
53
|
+
action: input.action === 'modify' ? 'changes-requested' : 'rejected',
|
|
54
|
+
message:
|
|
55
|
+
input.action === 'modify'
|
|
56
|
+
? `Requested changes for execution plan "${plan.title}".`
|
|
57
|
+
: `Rejected execution plan "${plan.title}".`,
|
|
58
|
+
hasPlan: true,
|
|
59
|
+
status: toolPlan.status,
|
|
60
|
+
plan: toolPlan,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
}
|