@lota-sdk/core 0.1.5
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 +55 -0
- package/infrastructure/schema/01_memory.surql +47 -0
- package/infrastructure/schema/02_execution_plan.surql +62 -0
- package/infrastructure/schema/03_learned_skill.surql +32 -0
- package/infrastructure/schema/04_runtime_bootstrap.surql +8 -0
- package/package.json +128 -0
- package/src/ai/definitions.ts +308 -0
- package/src/bifrost/bifrost.ts +256 -0
- package/src/config/agent-defaults.ts +99 -0
- package/src/config/constants.ts +33 -0
- package/src/config/env-shapes.ts +122 -0
- package/src/config/logger.ts +29 -0
- package/src/config/model-constants.ts +31 -0
- package/src/config/search.ts +17 -0
- package/src/config/workstream-defaults.ts +68 -0
- package/src/db/base.service.ts +55 -0
- package/src/db/cursor-pagination.ts +73 -0
- package/src/db/memory-query-builder.ts +207 -0
- package/src/db/memory-store.helpers.ts +118 -0
- package/src/db/memory-store.rows.ts +29 -0
- package/src/db/memory-store.ts +974 -0
- package/src/db/memory-types.ts +193 -0
- package/src/db/memory.ts +505 -0
- package/src/db/record-id.ts +78 -0
- package/src/db/service.ts +932 -0
- package/src/db/startup.ts +152 -0
- package/src/db/tables.ts +20 -0
- package/src/document/org-document-chunking.ts +224 -0
- package/src/document/parsing.ts +40 -0
- package/src/embeddings/provider.ts +76 -0
- package/src/index.ts +302 -0
- package/src/queues/context-compaction.queue.ts +82 -0
- package/src/queues/document-processor.queue.ts +118 -0
- package/src/queues/memory-consolidation.queue.ts +65 -0
- package/src/queues/post-chat-memory.queue.ts +128 -0
- package/src/queues/recent-activity-title-refinement.queue.ts +69 -0
- package/src/queues/regular-chat-memory-digest.config.ts +12 -0
- package/src/queues/regular-chat-memory-digest.queue.ts +73 -0
- package/src/queues/skill-extraction.config.ts +9 -0
- package/src/queues/skill-extraction.queue.ts +62 -0
- package/src/redis/connection.ts +176 -0
- package/src/redis/index.ts +30 -0
- package/src/redis/org-memory-lock.ts +43 -0
- package/src/redis/redis-lease-lock.ts +158 -0
- package/src/runtime/agent-contract.ts +1 -0
- package/src/runtime/agent-prompt-context.ts +119 -0
- package/src/runtime/agent-runtime-policy.ts +192 -0
- package/src/runtime/agent-stream-helpers.ts +117 -0
- package/src/runtime/agent-types.ts +22 -0
- package/src/runtime/approval-continuation.ts +16 -0
- package/src/runtime/chat-attachments.ts +46 -0
- package/src/runtime/chat-message.ts +10 -0
- package/src/runtime/chat-request-routing.ts +21 -0
- package/src/runtime/chat-run-orchestration.ts +25 -0
- package/src/runtime/chat-run-registry.ts +20 -0
- package/src/runtime/chat-types.ts +18 -0
- package/src/runtime/context-compaction-constants.ts +11 -0
- package/src/runtime/context-compaction-runtime.ts +86 -0
- package/src/runtime/context-compaction.ts +909 -0
- package/src/runtime/execution-plan.ts +59 -0
- package/src/runtime/helper-model.ts +405 -0
- package/src/runtime/indexed-repositories-policy.ts +28 -0
- package/src/runtime/instruction-sections.ts +8 -0
- package/src/runtime/llm-content.ts +71 -0
- package/src/runtime/memory-block.ts +264 -0
- package/src/runtime/memory-digest-policy.ts +14 -0
- package/src/runtime/memory-format.ts +8 -0
- package/src/runtime/memory-pipeline.ts +570 -0
- package/src/runtime/memory-prompts-fact.ts +47 -0
- package/src/runtime/memory-prompts-parse.ts +3 -0
- package/src/runtime/memory-prompts-update.ts +37 -0
- package/src/runtime/memory-scope.ts +43 -0
- package/src/runtime/plugin-types.ts +10 -0
- package/src/runtime/retrieval-adapters.ts +25 -0
- package/src/runtime/retrieval-pipeline.ts +3 -0
- package/src/runtime/runtime-extensions.ts +154 -0
- package/src/runtime/skill-extraction-policy.ts +3 -0
- package/src/runtime/team-consultation-orchestrator.ts +245 -0
- package/src/runtime/team-consultation-prompts.ts +32 -0
- package/src/runtime/title-helpers.ts +12 -0
- package/src/runtime/turn-lifecycle.ts +28 -0
- package/src/runtime/workstream-chat-helpers.ts +187 -0
- package/src/runtime/workstream-routing-policy.ts +301 -0
- package/src/runtime/workstream-state.ts +261 -0
- package/src/services/attachment.service.ts +159 -0
- package/src/services/chat-attachments.service.ts +17 -0
- package/src/services/chat-run-registry.service.ts +3 -0
- package/src/services/context-compaction-runtime.ts +13 -0
- package/src/services/context-compaction.service.ts +115 -0
- package/src/services/document-chunk.service.ts +141 -0
- package/src/services/execution-plan.service.ts +890 -0
- package/src/services/learned-skill.service.ts +328 -0
- package/src/services/memory-assessment.service.ts +43 -0
- package/src/services/memory.service.ts +807 -0
- package/src/services/memory.utils.ts +84 -0
- package/src/services/mutating-approval.service.ts +110 -0
- package/src/services/recent-activity-title.service.ts +74 -0
- package/src/services/recent-activity.service.ts +397 -0
- package/src/services/workstream-change-tracker.service.ts +313 -0
- package/src/services/workstream-message.service.ts +283 -0
- package/src/services/workstream-title.service.ts +58 -0
- package/src/services/workstream-turn-preparation.ts +1340 -0
- package/src/services/workstream-turn.ts +37 -0
- package/src/services/workstream.service.ts +854 -0
- package/src/services/workstream.types.ts +118 -0
- package/src/storage/attachment-parser.ts +101 -0
- package/src/storage/attachment-storage.service.ts +391 -0
- package/src/storage/attachments.types.ts +11 -0
- package/src/storage/attachments.utils.ts +58 -0
- package/src/storage/generated-document-storage.service.ts +55 -0
- package/src/system-agents/agent-result.ts +27 -0
- package/src/system-agents/context-compacter.agent.ts +46 -0
- package/src/system-agents/delegated-agent-factory.ts +177 -0
- package/src/system-agents/helper-agent-options.ts +20 -0
- package/src/system-agents/memory-reranker.agent.ts +38 -0
- package/src/system-agents/memory.agent.ts +58 -0
- package/src/system-agents/recent-activity-title-refiner.agent.ts +53 -0
- package/src/system-agents/regular-chat-memory-digest.agent.ts +75 -0
- package/src/system-agents/researcher.agent.ts +34 -0
- package/src/system-agents/skill-extractor.agent.ts +88 -0
- package/src/system-agents/skill-manager.agent.ts +80 -0
- package/src/system-agents/title-generator.agent.ts +42 -0
- package/src/system-agents/workstream-tracker.agent.ts +58 -0
- package/src/tools/execution-plan.tool.ts +163 -0
- package/src/tools/fetch-webpage.tool.ts +132 -0
- package/src/tools/firecrawl-client.ts +12 -0
- package/src/tools/memory-block.tool.ts +55 -0
- package/src/tools/read-file-parts.tool.ts +80 -0
- package/src/tools/remember-memory.tool.ts +85 -0
- package/src/tools/research-topic.tool.ts +15 -0
- package/src/tools/search-tools.ts +55 -0
- package/src/tools/search-web.tool.ts +175 -0
- package/src/tools/team-think.tool.ts +125 -0
- package/src/tools/tool-contract.ts +21 -0
- package/src/tools/user-questions.tool.ts +18 -0
- package/src/utils/async.ts +50 -0
- package/src/utils/date-time.ts +34 -0
- package/src/utils/error.ts +10 -0
- package/src/utils/errors.ts +28 -0
- package/src/utils/hono-error-handler.ts +71 -0
- package/src/utils/string.ts +51 -0
- package/src/workers/bootstrap.ts +44 -0
- package/src/workers/memory-consolidation.worker.ts +318 -0
- package/src/workers/regular-chat-memory-digest.helpers.ts +100 -0
- package/src/workers/regular-chat-memory-digest.runner.ts +363 -0
- package/src/workers/regular-chat-memory-digest.worker.ts +22 -0
- package/src/workers/skill-extraction.runner.ts +331 -0
- package/src/workers/skill-extraction.worker.ts +22 -0
- package/src/workers/utils/repo-indexer-chunker.ts +331 -0
- package/src/workers/utils/repo-structure-extractor.ts +645 -0
- package/src/workers/utils/repomix-process-concurrency.ts +65 -0
- package/src/workers/utils/sandbox-error.ts +5 -0
- package/src/workers/worker-utils.ts +182 -0
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ExecutionPlanEventSchema,
|
|
3
|
+
ExecutionPlanSchema,
|
|
4
|
+
ExecutionPlanTaskResultStatusSchema,
|
|
5
|
+
ExecutionPlanTaskSchema,
|
|
6
|
+
} from '@lota-sdk/shared/schemas/execution-plan'
|
|
7
|
+
import type {
|
|
8
|
+
ExecutionPlanEventRecord,
|
|
9
|
+
ExecutionPlanRecord,
|
|
10
|
+
ExecutionPlanStatus,
|
|
11
|
+
ExecutionPlanTaskRecord,
|
|
12
|
+
ExecutionPlanTaskResultStatus,
|
|
13
|
+
ExecutionPlanTaskStatus,
|
|
14
|
+
SerializableExecutionPlan,
|
|
15
|
+
SerializableExecutionPlanCarriedTask,
|
|
16
|
+
SerializableExecutionPlanEvent,
|
|
17
|
+
SerializableExecutionPlanTask,
|
|
18
|
+
} from '@lota-sdk/shared/schemas/execution-plan'
|
|
19
|
+
import type {
|
|
20
|
+
CreateExecutionPlanArgs,
|
|
21
|
+
ExecutionPlanToolResultData,
|
|
22
|
+
ReplaceExecutionPlanArgs,
|
|
23
|
+
SetExecutionTaskStatusArgs,
|
|
24
|
+
RestartExecutionTaskArgs,
|
|
25
|
+
} from '@lota-sdk/shared/schemas/tools'
|
|
26
|
+
import { BoundQuery, RecordId } from 'surrealdb'
|
|
27
|
+
|
|
28
|
+
import type { RecordIdInput, RecordIdRef } from '../db/record-id'
|
|
29
|
+
import { ensureRecordId, recordIdToString } from '../db/record-id'
|
|
30
|
+
import { databaseService } from '../db/service'
|
|
31
|
+
import { TABLES } from '../db/tables'
|
|
32
|
+
import { toIsoDateTimeString, toOptionalIsoDateTimeString } from '../utils/date-time'
|
|
33
|
+
|
|
34
|
+
const ACTIVE_PLAN_STATUSES: ExecutionPlanStatus[] = ['draft', 'executing', 'blocked']
|
|
35
|
+
const ACTIVE_PLAN_STATUS_SET = new Set<ExecutionPlanStatus>(ACTIVE_PLAN_STATUSES)
|
|
36
|
+
const SUCCESSFUL_RESULT_STATUS_SET = new Set<ExecutionPlanTaskResultStatus>(['success', 'partial'])
|
|
37
|
+
const CARRIED_RESULT_STATUS_SET = new Set<ExecutionPlanTaskResultStatus>(['success', 'partial'])
|
|
38
|
+
const RESTART_REQUIRED_TASK_STATUS_SET = new Set<ExecutionPlanTaskStatus>(['completed', 'failed', 'skipped'])
|
|
39
|
+
const TASK_STATUS_TRANSITIONS: Record<ExecutionPlanTaskStatus, ReadonlySet<ExecutionPlanTaskStatus>> = {
|
|
40
|
+
pending: new Set<ExecutionPlanTaskStatus>(['in-progress', 'blocked', 'skipped']),
|
|
41
|
+
'in-progress': new Set<ExecutionPlanTaskStatus>(['completed', 'blocked', 'failed', 'skipped']),
|
|
42
|
+
blocked: new Set<ExecutionPlanTaskStatus>(['in-progress', 'completed', 'failed', 'skipped']),
|
|
43
|
+
completed: new Set<ExecutionPlanTaskStatus>(),
|
|
44
|
+
failed: new Set<ExecutionPlanTaskStatus>(),
|
|
45
|
+
skipped: new Set<ExecutionPlanTaskStatus>(),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeTaskInput(
|
|
49
|
+
input: CreateExecutionPlanArgs['tasks'][number]['input'],
|
|
50
|
+
): string | Record<string, unknown> | undefined {
|
|
51
|
+
if (input === undefined) return undefined
|
|
52
|
+
return typeof input === 'string' ? input : structuredClone(input)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isExecutionPlanRecord(value: RecordIdInput | ExecutionPlanRecord): value is ExecutionPlanRecord {
|
|
56
|
+
return typeof value === 'object' && 'workstreamId' in value
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isExecutionPlanTaskRecord(value: RecordIdInput | ExecutionPlanTaskRecord): value is ExecutionPlanTaskRecord {
|
|
60
|
+
return typeof value === 'object' && 'position' in value
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toPlanId(planId: RecordIdInput | ExecutionPlanRecord): RecordIdRef {
|
|
64
|
+
return ensureRecordId(isExecutionPlanRecord(planId) ? planId.id : planId, TABLES.PLAN)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function toTaskId(taskId: RecordIdInput | ExecutionPlanTaskRecord): RecordIdRef {
|
|
68
|
+
return ensureRecordId(isExecutionPlanTaskRecord(taskId) ? taskId.id : taskId, TABLES.PLAN_TASK)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function toPlanIdString(planId: RecordIdInput | ExecutionPlanRecord): string {
|
|
72
|
+
return recordIdToString(toPlanId(planId), TABLES.PLAN)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function toTaskIdString(taskId: RecordIdInput | ExecutionPlanTaskRecord): string {
|
|
76
|
+
return recordIdToString(toTaskId(taskId), TABLES.PLAN_TASK)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function setDefinedOptionValue<TValue>(
|
|
80
|
+
target: Record<string, unknown>,
|
|
81
|
+
key: string,
|
|
82
|
+
value: TValue | null | undefined,
|
|
83
|
+
mapValue?: (value: TValue) => unknown,
|
|
84
|
+
) {
|
|
85
|
+
if (value === undefined || value === null) return
|
|
86
|
+
target[key] = mapValue ? mapValue(value) : value
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function pushOptionalAssignment<TValue>(
|
|
90
|
+
assignments: string[],
|
|
91
|
+
bindings: Record<string, unknown>,
|
|
92
|
+
key: string,
|
|
93
|
+
value: TValue | null | undefined,
|
|
94
|
+
mapValue?: (value: TValue) => unknown,
|
|
95
|
+
) {
|
|
96
|
+
if (value === undefined) return
|
|
97
|
+
if (value === null) {
|
|
98
|
+
assignments.push(`${key} = NONE`)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
assignments.push(`${key} = $${key}`)
|
|
103
|
+
bindings[key] = mapValue ? mapValue(value) : value
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function serializeTask(task: ExecutionPlanTaskRecord): SerializableExecutionPlanTask {
|
|
107
|
+
return {
|
|
108
|
+
id: recordIdToString(task.id, TABLES.PLAN_TASK),
|
|
109
|
+
position: task.position,
|
|
110
|
+
title: task.title,
|
|
111
|
+
rationale: task.rationale,
|
|
112
|
+
kind: task.kind,
|
|
113
|
+
ownerType: task.ownerType,
|
|
114
|
+
ownerRef: task.ownerRef,
|
|
115
|
+
status: task.status,
|
|
116
|
+
resultStatus: task.resultStatus,
|
|
117
|
+
...(task.input !== undefined ? { input: task.input } : {}),
|
|
118
|
+
outputSummary: task.outputSummary,
|
|
119
|
+
blockedReason: task.blockedReason,
|
|
120
|
+
externalRef: task.externalRef,
|
|
121
|
+
retryCount: task.retryCount,
|
|
122
|
+
idempotencyKey: task.idempotencyKey,
|
|
123
|
+
startedAt: toOptionalIsoDateTimeString(task.startedAt),
|
|
124
|
+
completedAt: toOptionalIsoDateTimeString(task.completedAt),
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function serializeEvent(event: ExecutionPlanEventRecord): SerializableExecutionPlanEvent {
|
|
129
|
+
return {
|
|
130
|
+
id: recordIdToString(event.id, TABLES.PLAN_EVENT),
|
|
131
|
+
taskId: event.taskId ? recordIdToString(event.taskId, TABLES.PLAN_TASK) : undefined,
|
|
132
|
+
eventType: event.eventType,
|
|
133
|
+
fromStatus: event.fromStatus,
|
|
134
|
+
toStatus: event.toStatus,
|
|
135
|
+
message: event.message,
|
|
136
|
+
detail: event.detail,
|
|
137
|
+
emittedBy: event.emittedBy,
|
|
138
|
+
createdAt: toIsoDateTimeString(event.createdAt),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildProgress(tasks: ExecutionPlanTaskRecord[]) {
|
|
143
|
+
const counts = tasks.reduce(
|
|
144
|
+
(summary, task) => {
|
|
145
|
+
summary[task.status] += 1
|
|
146
|
+
return summary
|
|
147
|
+
},
|
|
148
|
+
{ pending: 0, 'in-progress': 0, completed: 0, blocked: 0, failed: 0, skipped: 0 } satisfies Record<
|
|
149
|
+
ExecutionPlanTaskStatus,
|
|
150
|
+
number
|
|
151
|
+
>,
|
|
152
|
+
)
|
|
153
|
+
const total = tasks.length
|
|
154
|
+
const completedWork = counts.completed + counts.skipped
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
total,
|
|
158
|
+
pending: counts.pending,
|
|
159
|
+
inProgress: counts['in-progress'],
|
|
160
|
+
completed: counts.completed,
|
|
161
|
+
blocked: counts.blocked,
|
|
162
|
+
failed: counts.failed,
|
|
163
|
+
skipped: counts.skipped,
|
|
164
|
+
completionRatio: total > 0 ? Number((completedWork / total).toFixed(4)) : undefined,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function findCurrentTask(
|
|
169
|
+
tasks: ExecutionPlanTaskRecord[],
|
|
170
|
+
currentTaskId: string | Record<string, unknown> | null | undefined,
|
|
171
|
+
) {
|
|
172
|
+
if (currentTaskId) {
|
|
173
|
+
const normalized = recordIdToString(currentTaskId as RecordIdInput, TABLES.PLAN_TASK)
|
|
174
|
+
const matched = tasks.find((task) => recordIdToString(task.id, TABLES.PLAN_TASK) === normalized)
|
|
175
|
+
if (matched) return matched
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return tasks.find((task) => task.status === 'in-progress') ?? tasks.find((task) => task.status === 'pending') ?? null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function assertTaskStatusTransition(
|
|
182
|
+
task: ExecutionPlanTaskRecord,
|
|
183
|
+
nextStatus: ExecutionPlanTaskStatus,
|
|
184
|
+
taskTitle: string,
|
|
185
|
+
): void {
|
|
186
|
+
if (task.status === nextStatus) {
|
|
187
|
+
throw new Error(`Execution task "${taskTitle}" is already ${nextStatus}.`)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (RESTART_REQUIRED_TASK_STATUS_SET.has(task.status)) {
|
|
191
|
+
throw new Error(`Use restartExecutionTask before changing execution task "${taskTitle}" from ${task.status}.`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (TASK_STATUS_TRANSITIONS[task.status].has(nextStatus)) {
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
throw new Error(`Cannot change execution task "${taskTitle}" from ${task.status} to ${nextStatus}.`)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
class ExecutionPlanService {
|
|
202
|
+
private async getTaskById(taskId: RecordIdInput): Promise<ExecutionPlanTaskRecord> {
|
|
203
|
+
const recordId = ensureRecordId(taskId, TABLES.PLAN_TASK)
|
|
204
|
+
const row = await databaseService.findOne(TABLES.PLAN_TASK, { id: recordId }, ExecutionPlanTaskSchema)
|
|
205
|
+
if (!row) {
|
|
206
|
+
throw new Error(`Execution plan task not found: ${recordId.toString()}`)
|
|
207
|
+
}
|
|
208
|
+
return row
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private async listPlanTasks(planId: RecordIdInput): Promise<ExecutionPlanTaskRecord[]> {
|
|
212
|
+
return await databaseService.findMany(
|
|
213
|
+
TABLES.PLAN_TASK,
|
|
214
|
+
{ planId: ensureRecordId(planId, TABLES.PLAN) },
|
|
215
|
+
ExecutionPlanTaskSchema,
|
|
216
|
+
{ orderBy: 'position', orderDir: 'ASC' },
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private async listPlanEvents(planId: RecordIdInput, limit = 20): Promise<ExecutionPlanEventRecord[]> {
|
|
221
|
+
return (
|
|
222
|
+
await databaseService.findMany(
|
|
223
|
+
TABLES.PLAN_EVENT,
|
|
224
|
+
{ planId: ensureRecordId(planId, TABLES.PLAN) },
|
|
225
|
+
ExecutionPlanEventSchema,
|
|
226
|
+
{ orderBy: 'createdAt', orderDir: 'DESC', limit },
|
|
227
|
+
)
|
|
228
|
+
).reverse()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private async emitEvent(params: {
|
|
232
|
+
planId: RecordIdInput
|
|
233
|
+
taskId?: RecordIdInput | null
|
|
234
|
+
eventType: ExecutionPlanEventRecord['eventType']
|
|
235
|
+
fromStatus?: string | null
|
|
236
|
+
toStatus?: string | null
|
|
237
|
+
message: string
|
|
238
|
+
detail?: Record<string, unknown> | null
|
|
239
|
+
emittedBy: string
|
|
240
|
+
}): Promise<void> {
|
|
241
|
+
const eventData: Record<string, unknown> = {
|
|
242
|
+
planId: ensureRecordId(params.planId, TABLES.PLAN),
|
|
243
|
+
eventType: params.eventType,
|
|
244
|
+
message: params.message,
|
|
245
|
+
emittedBy: params.emittedBy,
|
|
246
|
+
}
|
|
247
|
+
setDefinedOptionValue(eventData, 'taskId', params.taskId, (taskId) => ensureRecordId(taskId, TABLES.PLAN_TASK))
|
|
248
|
+
setDefinedOptionValue(eventData, 'fromStatus', params.fromStatus)
|
|
249
|
+
setDefinedOptionValue(eventData, 'toStatus', params.toStatus)
|
|
250
|
+
setDefinedOptionValue(eventData, 'detail', params.detail)
|
|
251
|
+
|
|
252
|
+
await databaseService.create(TABLES.PLAN_EVENT, eventData, ExecutionPlanEventSchema)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private async updatePlanStatus(params: {
|
|
256
|
+
plan: ExecutionPlanRecord
|
|
257
|
+
status: ExecutionPlanStatus
|
|
258
|
+
currentTaskId?: RecordIdInput | null
|
|
259
|
+
restartFromTaskId?: RecordIdInput | null
|
|
260
|
+
failureCount?: number
|
|
261
|
+
startedAt?: Date | null
|
|
262
|
+
completedAt?: Date | null
|
|
263
|
+
emittedBy: string
|
|
264
|
+
message?: string
|
|
265
|
+
detail?: Record<string, unknown>
|
|
266
|
+
}): Promise<ExecutionPlanRecord> {
|
|
267
|
+
const bindings: Record<string, unknown> = { status: params.status }
|
|
268
|
+
const assignments = ['status = $status']
|
|
269
|
+
pushOptionalAssignment(assignments, bindings, 'currentTaskId', params.currentTaskId, (taskId) =>
|
|
270
|
+
ensureRecordId(taskId, TABLES.PLAN_TASK),
|
|
271
|
+
)
|
|
272
|
+
pushOptionalAssignment(assignments, bindings, 'restartFromTaskId', params.restartFromTaskId, (taskId) =>
|
|
273
|
+
ensureRecordId(taskId, TABLES.PLAN_TASK),
|
|
274
|
+
)
|
|
275
|
+
if (params.failureCount !== undefined) {
|
|
276
|
+
assignments.push('failureCount = $failureCount')
|
|
277
|
+
bindings.failureCount = params.failureCount
|
|
278
|
+
}
|
|
279
|
+
pushOptionalAssignment(assignments, bindings, 'startedAt', params.startedAt)
|
|
280
|
+
pushOptionalAssignment(assignments, bindings, 'completedAt', params.completedAt)
|
|
281
|
+
|
|
282
|
+
const rows = await databaseService.query<unknown>(
|
|
283
|
+
new BoundQuery(`UPDATE ONLY ${toPlanIdString(params.plan)} SET ${assignments.join(', ')} RETURN AFTER`, bindings),
|
|
284
|
+
)
|
|
285
|
+
const next = rows.at(0) ? ExecutionPlanSchema.parse(rows[0]) : null
|
|
286
|
+
|
|
287
|
+
if (!next) {
|
|
288
|
+
throw new Error(`Failed to update execution plan ${recordIdToString(params.plan.id, TABLES.PLAN)}`)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (params.plan.status !== next.status) {
|
|
292
|
+
await this.emitEvent({
|
|
293
|
+
planId: next.id,
|
|
294
|
+
eventType: 'plan-status-changed',
|
|
295
|
+
fromStatus: params.plan.status,
|
|
296
|
+
toStatus: next.status,
|
|
297
|
+
message: params.message ?? `Plan status changed from ${params.plan.status} to ${next.status}.`,
|
|
298
|
+
detail: params.detail,
|
|
299
|
+
emittedBy: params.emittedBy,
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return next
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private async updateTaskRecord(params: {
|
|
307
|
+
task: ExecutionPlanTaskRecord
|
|
308
|
+
status: ExecutionPlanTaskStatus
|
|
309
|
+
resultStatus: ExecutionPlanTaskResultStatus
|
|
310
|
+
outputSummary?: string | null
|
|
311
|
+
blockedReason?: string | null
|
|
312
|
+
externalRef?: string | null
|
|
313
|
+
startedAt?: Date | null
|
|
314
|
+
completedAt?: Date | null
|
|
315
|
+
}): Promise<ExecutionPlanTaskRecord> {
|
|
316
|
+
const bindings: Record<string, unknown> = { status: params.status, resultStatus: params.resultStatus }
|
|
317
|
+
const assignments = ['status = $status', 'resultStatus = $resultStatus']
|
|
318
|
+
pushOptionalAssignment(assignments, bindings, 'outputSummary', params.outputSummary)
|
|
319
|
+
pushOptionalAssignment(assignments, bindings, 'blockedReason', params.blockedReason)
|
|
320
|
+
pushOptionalAssignment(assignments, bindings, 'externalRef', params.externalRef)
|
|
321
|
+
pushOptionalAssignment(assignments, bindings, 'startedAt', params.startedAt)
|
|
322
|
+
pushOptionalAssignment(assignments, bindings, 'completedAt', params.completedAt)
|
|
323
|
+
|
|
324
|
+
const rows = await databaseService.query<unknown>(
|
|
325
|
+
new BoundQuery(`UPDATE ONLY ${toTaskIdString(params.task)} SET ${assignments.join(', ')} RETURN AFTER`, bindings),
|
|
326
|
+
)
|
|
327
|
+
const next = rows.at(0) ? ExecutionPlanTaskSchema.parse(rows[0]) : null
|
|
328
|
+
if (!next) {
|
|
329
|
+
throw new Error(`Failed to update execution task ${recordIdToString(params.task.id, TABLES.PLAN_TASK)}`)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return next
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private async getActivePlanRecord(workstreamId: RecordIdInput): Promise<ExecutionPlanRecord | null> {
|
|
336
|
+
const plans = await databaseService.findMany(
|
|
337
|
+
TABLES.PLAN,
|
|
338
|
+
{ workstreamId: ensureRecordId(workstreamId, TABLES.WORKSTREAM) },
|
|
339
|
+
ExecutionPlanSchema,
|
|
340
|
+
{ orderBy: 'updatedAt', orderDir: 'DESC' },
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
return plans.find((plan) => ACTIVE_PLAN_STATUS_SET.has(plan.status)) ?? null
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private async collectCarriedTasks(plan: ExecutionPlanRecord): Promise<SerializableExecutionPlanCarriedTask[]> {
|
|
347
|
+
const carried: SerializableExecutionPlanCarriedTask[] = []
|
|
348
|
+
let currentReplacedPlanId = plan.replacedPlanId ? ensureRecordId(plan.replacedPlanId, TABLES.PLAN) : null
|
|
349
|
+
let depth = 0
|
|
350
|
+
|
|
351
|
+
while (currentReplacedPlanId && depth < 5) {
|
|
352
|
+
const previousPlan = await databaseService.findOne(
|
|
353
|
+
TABLES.PLAN,
|
|
354
|
+
{ id: currentReplacedPlanId },
|
|
355
|
+
ExecutionPlanSchema,
|
|
356
|
+
)
|
|
357
|
+
if (!previousPlan) break
|
|
358
|
+
|
|
359
|
+
const previousTasks = await this.listPlanTasks(previousPlan.id)
|
|
360
|
+
const currentPlanId = recordIdToString(previousPlan.id, TABLES.PLAN)
|
|
361
|
+
carried.unshift(
|
|
362
|
+
...previousTasks
|
|
363
|
+
.filter(
|
|
364
|
+
(task) =>
|
|
365
|
+
CARRIED_RESULT_STATUS_SET.has(task.resultStatus) &&
|
|
366
|
+
typeof task.outputSummary === 'string' &&
|
|
367
|
+
task.outputSummary.trim().length > 0,
|
|
368
|
+
)
|
|
369
|
+
.map((task) => ({
|
|
370
|
+
planId: currentPlanId,
|
|
371
|
+
taskId: recordIdToString(task.id, TABLES.PLAN_TASK),
|
|
372
|
+
title: task.title,
|
|
373
|
+
resultStatus: task.resultStatus,
|
|
374
|
+
outputSummary: task.outputSummary ?? '',
|
|
375
|
+
})),
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
currentReplacedPlanId = previousPlan.replacedPlanId
|
|
379
|
+
? ensureRecordId(previousPlan.replacedPlanId, TABLES.PLAN)
|
|
380
|
+
: null
|
|
381
|
+
depth += 1
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return carried
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private async toSerializablePlan(
|
|
388
|
+
plan: ExecutionPlanRecord,
|
|
389
|
+
options?: { includeEvents?: boolean },
|
|
390
|
+
): Promise<SerializableExecutionPlan> {
|
|
391
|
+
const tasks = await this.listPlanTasks(plan.id)
|
|
392
|
+
const recentEvents = options?.includeEvents ? await this.listPlanEvents(plan.id, 20) : []
|
|
393
|
+
const carriedTasks = await this.collectCarriedTasks(plan)
|
|
394
|
+
const currentTask = findCurrentTask(tasks, plan.currentTaskId)
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
planId: recordIdToString(plan.id, TABLES.PLAN),
|
|
398
|
+
workstreamId: recordIdToString(plan.workstreamId, TABLES.WORKSTREAM),
|
|
399
|
+
organizationId: recordIdToString(plan.organizationId, TABLES.ORGANIZATION),
|
|
400
|
+
title: plan.title,
|
|
401
|
+
objective: plan.objective,
|
|
402
|
+
status: plan.status,
|
|
403
|
+
leadAgentId: plan.leadAgentId,
|
|
404
|
+
currentTaskId: currentTask ? recordIdToString(currentTask.id, TABLES.PLAN_TASK) : undefined,
|
|
405
|
+
restartFromTaskId: plan.restartFromTaskId
|
|
406
|
+
? recordIdToString(plan.restartFromTaskId, TABLES.PLAN_TASK)
|
|
407
|
+
: undefined,
|
|
408
|
+
replacedPlanId: plan.replacedPlanId ? recordIdToString(plan.replacedPlanId, TABLES.PLAN) : undefined,
|
|
409
|
+
failureCount: plan.failureCount,
|
|
410
|
+
startedAt: toOptionalIsoDateTimeString(plan.startedAt),
|
|
411
|
+
completedAt: toOptionalIsoDateTimeString(plan.completedAt),
|
|
412
|
+
progress: buildProgress(tasks),
|
|
413
|
+
tasks: tasks.map(serializeTask),
|
|
414
|
+
recentEvents: recentEvents.map(serializeEvent),
|
|
415
|
+
carriedTasks,
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private buildToolResult(params: {
|
|
420
|
+
action: ExecutionPlanToolResultData['action']
|
|
421
|
+
plan: SerializableExecutionPlan | null
|
|
422
|
+
message?: string
|
|
423
|
+
changedTaskId?: string
|
|
424
|
+
}): ExecutionPlanToolResultData {
|
|
425
|
+
return {
|
|
426
|
+
action: params.action,
|
|
427
|
+
...(params.message ? { message: params.message } : {}),
|
|
428
|
+
...(params.changedTaskId !== undefined ? { changedTaskId: params.changedTaskId } : {}),
|
|
429
|
+
...(params.plan ? { plan: params.plan } : {}),
|
|
430
|
+
hasPlan: params.plan !== null,
|
|
431
|
+
status: params.plan?.status,
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async hasActivePlan(workstreamId: RecordIdInput): Promise<boolean> {
|
|
436
|
+
return (await this.getActivePlanRecord(workstreamId)) !== null
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async getActivePlanForWorkstream(
|
|
440
|
+
workstreamId: RecordIdInput,
|
|
441
|
+
options?: { includeEvents?: boolean },
|
|
442
|
+
): Promise<SerializableExecutionPlan | null> {
|
|
443
|
+
const plan = await this.getActivePlanRecord(workstreamId)
|
|
444
|
+
if (!plan) return null
|
|
445
|
+
return await this.toSerializablePlan(plan, options)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async createPlan(params: {
|
|
449
|
+
organizationId: RecordIdInput
|
|
450
|
+
workstreamId: RecordIdInput
|
|
451
|
+
leadAgentId: ExecutionPlanRecord['leadAgentId']
|
|
452
|
+
input: CreateExecutionPlanArgs
|
|
453
|
+
}): Promise<ExecutionPlanToolResultData> {
|
|
454
|
+
const activePlan = await this.getActivePlanRecord(params.workstreamId)
|
|
455
|
+
if (activePlan) {
|
|
456
|
+
throw new Error('An active execution plan already exists for this workstream. Replace it instead.')
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const planId = new RecordId(TABLES.PLAN, Bun.randomUUIDv7())
|
|
460
|
+
const taskIds = params.input.tasks.map(() => new RecordId(TABLES.PLAN_TASK, Bun.randomUUIDv7()))
|
|
461
|
+
const organizationId = ensureRecordId(params.organizationId, TABLES.ORGANIZATION)
|
|
462
|
+
const workstreamId = ensureRecordId(params.workstreamId, TABLES.WORKSTREAM)
|
|
463
|
+
const planData: Record<string, unknown> = {
|
|
464
|
+
organizationId,
|
|
465
|
+
workstreamId,
|
|
466
|
+
title: params.input.title,
|
|
467
|
+
objective: params.input.objective,
|
|
468
|
+
status: 'draft',
|
|
469
|
+
leadAgentId: params.leadAgentId,
|
|
470
|
+
currentTaskId: taskIds[0],
|
|
471
|
+
failureCount: 0,
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const plan = await databaseService.createWithId(TABLES.PLAN, planId, planData, ExecutionPlanSchema)
|
|
475
|
+
|
|
476
|
+
for (const [index, task] of params.input.tasks.entries()) {
|
|
477
|
+
const taskData: Record<string, unknown> = {
|
|
478
|
+
planId,
|
|
479
|
+
organizationId,
|
|
480
|
+
workstreamId,
|
|
481
|
+
position: index,
|
|
482
|
+
title: task.title,
|
|
483
|
+
rationale: task.rationale,
|
|
484
|
+
kind: task.kind,
|
|
485
|
+
ownerType: task.ownerType,
|
|
486
|
+
ownerRef: task.ownerRef,
|
|
487
|
+
status: 'pending',
|
|
488
|
+
resultStatus: 'not-started',
|
|
489
|
+
retryCount: 0,
|
|
490
|
+
idempotencyKey: `${planId.toString()}:${index}`,
|
|
491
|
+
}
|
|
492
|
+
setDefinedOptionValue(taskData, 'input', task.input, normalizeTaskInput)
|
|
493
|
+
|
|
494
|
+
await databaseService.createWithId(TABLES.PLAN_TASK, taskIds[index], taskData, ExecutionPlanTaskSchema)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
await this.emitEvent({
|
|
498
|
+
planId: plan.id,
|
|
499
|
+
eventType: 'plan-created',
|
|
500
|
+
message: `Created execution plan "${plan.title}" with ${params.input.tasks.length} tasks.`,
|
|
501
|
+
detail: { title: plan.title, objective: plan.objective, taskCount: params.input.tasks.length },
|
|
502
|
+
emittedBy: params.leadAgentId,
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
const serializable = await this.toSerializablePlan(plan, { includeEvents: true })
|
|
506
|
+
return this.buildToolResult({
|
|
507
|
+
action: 'created',
|
|
508
|
+
plan: serializable,
|
|
509
|
+
message: `Created execution plan "${plan.title}".`,
|
|
510
|
+
})
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async replacePlan(params: {
|
|
514
|
+
workstreamId: RecordIdInput
|
|
515
|
+
organizationId: RecordIdInput
|
|
516
|
+
leadAgentId: ExecutionPlanRecord['leadAgentId']
|
|
517
|
+
input: ReplaceExecutionPlanArgs
|
|
518
|
+
}): Promise<ExecutionPlanToolResultData> {
|
|
519
|
+
const activePlan = await this.getActivePlanRecord(params.workstreamId)
|
|
520
|
+
if (!activePlan) {
|
|
521
|
+
throw new Error('No active execution plan exists for this workstream.')
|
|
522
|
+
}
|
|
523
|
+
if (recordIdToString(activePlan.id, TABLES.PLAN) !== params.input.planId) {
|
|
524
|
+
throw new Error('Only the active execution plan can be replaced.')
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const oldPlan = await this.updatePlanStatus({
|
|
528
|
+
plan: activePlan,
|
|
529
|
+
status: 'aborted',
|
|
530
|
+
currentTaskId: activePlan.currentTaskId,
|
|
531
|
+
restartFromTaskId: activePlan.restartFromTaskId,
|
|
532
|
+
emittedBy: params.leadAgentId,
|
|
533
|
+
message: `Plan "${activePlan.title}" was replaced.`,
|
|
534
|
+
detail: { reason: params.input.reason },
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
const planId = new RecordId(TABLES.PLAN, Bun.randomUUIDv7())
|
|
538
|
+
const taskIds = params.input.tasks.map(() => new RecordId(TABLES.PLAN_TASK, Bun.randomUUIDv7()))
|
|
539
|
+
const organizationId = ensureRecordId(params.organizationId, TABLES.ORGANIZATION)
|
|
540
|
+
const workstreamId = ensureRecordId(params.workstreamId, TABLES.WORKSTREAM)
|
|
541
|
+
const nextPlanData: Record<string, unknown> = {
|
|
542
|
+
organizationId,
|
|
543
|
+
workstreamId,
|
|
544
|
+
title: params.input.title,
|
|
545
|
+
objective: params.input.objective,
|
|
546
|
+
status: 'draft',
|
|
547
|
+
leadAgentId: params.leadAgentId,
|
|
548
|
+
currentTaskId: taskIds[0],
|
|
549
|
+
replacedPlanId: ensureRecordId(oldPlan.id, TABLES.PLAN),
|
|
550
|
+
failureCount: 0,
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const nextPlan = await databaseService.createWithId(TABLES.PLAN, planId, nextPlanData, ExecutionPlanSchema)
|
|
554
|
+
|
|
555
|
+
for (const [index, task] of params.input.tasks.entries()) {
|
|
556
|
+
const taskData: Record<string, unknown> = {
|
|
557
|
+
planId,
|
|
558
|
+
organizationId,
|
|
559
|
+
workstreamId,
|
|
560
|
+
position: index,
|
|
561
|
+
title: task.title,
|
|
562
|
+
rationale: task.rationale,
|
|
563
|
+
kind: task.kind,
|
|
564
|
+
ownerType: task.ownerType,
|
|
565
|
+
ownerRef: task.ownerRef,
|
|
566
|
+
status: 'pending',
|
|
567
|
+
resultStatus: 'not-started',
|
|
568
|
+
retryCount: 0,
|
|
569
|
+
idempotencyKey: `${planId.toString()}:${index}`,
|
|
570
|
+
}
|
|
571
|
+
setDefinedOptionValue(taskData, 'input', task.input, normalizeTaskInput)
|
|
572
|
+
|
|
573
|
+
await databaseService.createWithId(TABLES.PLAN_TASK, taskIds[index], taskData, ExecutionPlanTaskSchema)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
await this.emitEvent({
|
|
577
|
+
planId: oldPlan.id,
|
|
578
|
+
eventType: 'replan',
|
|
579
|
+
message: `Created replacement plan "${nextPlan.title}".`,
|
|
580
|
+
detail: {
|
|
581
|
+
reason: params.input.reason,
|
|
582
|
+
oldPlanId: recordIdToString(oldPlan.id, TABLES.PLAN),
|
|
583
|
+
newPlanId: recordIdToString(nextPlan.id, TABLES.PLAN),
|
|
584
|
+
},
|
|
585
|
+
emittedBy: params.leadAgentId,
|
|
586
|
+
})
|
|
587
|
+
await this.emitEvent({
|
|
588
|
+
planId: nextPlan.id,
|
|
589
|
+
eventType: 'plan-created',
|
|
590
|
+
message: `Created execution plan "${nextPlan.title}" with ${params.input.tasks.length} tasks.`,
|
|
591
|
+
detail: {
|
|
592
|
+
title: nextPlan.title,
|
|
593
|
+
objective: nextPlan.objective,
|
|
594
|
+
taskCount: params.input.tasks.length,
|
|
595
|
+
replacedPlanId: recordIdToString(oldPlan.id, TABLES.PLAN),
|
|
596
|
+
},
|
|
597
|
+
emittedBy: params.leadAgentId,
|
|
598
|
+
})
|
|
599
|
+
await this.emitEvent({
|
|
600
|
+
planId: nextPlan.id,
|
|
601
|
+
eventType: 'plan-replaced',
|
|
602
|
+
message: `Plan "${nextPlan.title}" replaces "${oldPlan.title}".`,
|
|
603
|
+
detail: { reason: params.input.reason, replacedPlanId: recordIdToString(oldPlan.id, TABLES.PLAN) },
|
|
604
|
+
emittedBy: params.leadAgentId,
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
const serializable = await this.toSerializablePlan(nextPlan, { includeEvents: true })
|
|
608
|
+
return this.buildToolResult({
|
|
609
|
+
action: 'replaced',
|
|
610
|
+
plan: serializable,
|
|
611
|
+
message: `Replaced execution plan with "${nextPlan.title}".`,
|
|
612
|
+
})
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async getActivePlanToolResult(params: {
|
|
616
|
+
workstreamId: RecordIdInput
|
|
617
|
+
includeEvents?: boolean
|
|
618
|
+
}): Promise<ExecutionPlanToolResultData> {
|
|
619
|
+
const plan = await this.getActivePlanForWorkstream(params.workstreamId, { includeEvents: params.includeEvents })
|
|
620
|
+
return this.buildToolResult({
|
|
621
|
+
action: plan ? 'loaded' : 'none',
|
|
622
|
+
plan,
|
|
623
|
+
...(plan ? { message: `Loaded execution plan "${plan.title}".` } : { message: 'No active execution plan.' }),
|
|
624
|
+
})
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async setTaskStatus(params: {
|
|
628
|
+
workstreamId: RecordIdInput
|
|
629
|
+
emittedBy: string
|
|
630
|
+
input: SetExecutionTaskStatusArgs
|
|
631
|
+
}): Promise<ExecutionPlanToolResultData> {
|
|
632
|
+
const activePlan = await this.getActivePlanRecord(params.workstreamId)
|
|
633
|
+
if (!activePlan) {
|
|
634
|
+
throw new Error('No active execution plan exists for this workstream.')
|
|
635
|
+
}
|
|
636
|
+
if (recordIdToString(activePlan.id, TABLES.PLAN) !== params.input.planId) {
|
|
637
|
+
throw new Error('Task updates must target the active execution plan.')
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const task = await this.getTaskById(params.input.taskId)
|
|
641
|
+
if (recordIdToString(task.planId, TABLES.PLAN) !== params.input.planId) {
|
|
642
|
+
throw new Error('Execution task does not belong to the provided plan.')
|
|
643
|
+
}
|
|
644
|
+
assertTaskStatusTransition(task, params.input.status, task.title)
|
|
645
|
+
|
|
646
|
+
const tasks = await this.listPlanTasks(activePlan.id)
|
|
647
|
+
const otherInProgressTask = tasks.find(
|
|
648
|
+
(candidate) =>
|
|
649
|
+
candidate.status === 'in-progress' &&
|
|
650
|
+
recordIdToString(candidate.id, TABLES.PLAN_TASK) !== recordIdToString(task.id, TABLES.PLAN_TASK),
|
|
651
|
+
)
|
|
652
|
+
if (params.input.status === 'in-progress' && otherInProgressTask) {
|
|
653
|
+
throw new Error('Only one execution-plan task may be in progress at a time.')
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (params.input.status === 'pending') {
|
|
657
|
+
throw new Error('Use restartExecutionTask to reset a task back to pending.')
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (params.input.status === 'completed') {
|
|
661
|
+
if (!params.input.resultStatus) {
|
|
662
|
+
throw new Error('Completed tasks require resultStatus.')
|
|
663
|
+
}
|
|
664
|
+
if (!SUCCESSFUL_RESULT_STATUS_SET.has(params.input.resultStatus)) {
|
|
665
|
+
throw new Error('Completed tasks require resultStatus to be success or partial.')
|
|
666
|
+
}
|
|
667
|
+
if (!params.input.outputSummary?.trim() && !params.input.externalRef?.trim()) {
|
|
668
|
+
throw new Error('Completed tasks require outputSummary or externalRef.')
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (params.input.status === 'blocked' && !params.input.blockedReason?.trim()) {
|
|
673
|
+
throw new Error('Blocked tasks require blockedReason.')
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (params.input.status === 'failed' && !params.input.outputSummary?.trim()) {
|
|
677
|
+
throw new Error('Failed tasks require outputSummary.')
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const now = new Date()
|
|
681
|
+
let nextPlan = activePlan
|
|
682
|
+
let nextResultStatus = task.resultStatus
|
|
683
|
+
let nextFailureCount = activePlan.failureCount
|
|
684
|
+
let taskStartedAt: Date | null | undefined = task.startedAt
|
|
685
|
+
? undefined
|
|
686
|
+
: params.input.status === 'in-progress'
|
|
687
|
+
? now
|
|
688
|
+
: undefined
|
|
689
|
+
let taskCompletedAt: Date | null | undefined = undefined
|
|
690
|
+
let message = ''
|
|
691
|
+
|
|
692
|
+
if (params.input.status === 'in-progress') {
|
|
693
|
+
nextPlan = await this.updatePlanStatus({
|
|
694
|
+
plan: activePlan,
|
|
695
|
+
status: 'executing',
|
|
696
|
+
currentTaskId: task.id,
|
|
697
|
+
restartFromTaskId: null,
|
|
698
|
+
...(activePlan.startedAt ? {} : { startedAt: now }),
|
|
699
|
+
emittedBy: params.emittedBy,
|
|
700
|
+
})
|
|
701
|
+
nextResultStatus = task.resultStatus
|
|
702
|
+
message = `Started task "${task.title}".`
|
|
703
|
+
} else if (params.input.status === 'completed' || params.input.status === 'skipped') {
|
|
704
|
+
nextResultStatus =
|
|
705
|
+
params.input.status === 'completed'
|
|
706
|
+
? ExecutionPlanTaskResultStatusSchema.parse(params.input.resultStatus)
|
|
707
|
+
: (params.input.resultStatus ?? 'not-started')
|
|
708
|
+
taskCompletedAt = now
|
|
709
|
+
|
|
710
|
+
const refreshedTasks = tasks.map((candidate) =>
|
|
711
|
+
recordIdToString(candidate.id, TABLES.PLAN_TASK) === recordIdToString(task.id, TABLES.PLAN_TASK)
|
|
712
|
+
? { ...candidate, status: params.input.status, resultStatus: nextResultStatus }
|
|
713
|
+
: candidate,
|
|
714
|
+
)
|
|
715
|
+
const nextPendingTask =
|
|
716
|
+
refreshedTasks.find((candidate) => candidate.position > task.position && candidate.status === 'pending') ??
|
|
717
|
+
refreshedTasks.find((candidate) => candidate.status === 'pending') ??
|
|
718
|
+
null
|
|
719
|
+
|
|
720
|
+
if (nextPendingTask) {
|
|
721
|
+
nextPlan = await this.updatePlanStatus({
|
|
722
|
+
plan: activePlan,
|
|
723
|
+
status: 'executing',
|
|
724
|
+
currentTaskId: nextPendingTask.id,
|
|
725
|
+
restartFromTaskId: null,
|
|
726
|
+
failureCount: 0,
|
|
727
|
+
emittedBy: params.emittedBy,
|
|
728
|
+
})
|
|
729
|
+
} else {
|
|
730
|
+
nextPlan = await this.updatePlanStatus({
|
|
731
|
+
plan: activePlan,
|
|
732
|
+
status: 'completed',
|
|
733
|
+
currentTaskId: null,
|
|
734
|
+
restartFromTaskId: null,
|
|
735
|
+
failureCount: 0,
|
|
736
|
+
completedAt: now,
|
|
737
|
+
...(activePlan.startedAt ? {} : { startedAt: now }),
|
|
738
|
+
emittedBy: params.emittedBy,
|
|
739
|
+
})
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
nextFailureCount = 0
|
|
743
|
+
message =
|
|
744
|
+
params.input.status === 'completed' ? `Completed task "${task.title}".` : `Skipped task "${task.title}".`
|
|
745
|
+
} else {
|
|
746
|
+
nextFailureCount = activePlan.failureCount + 1
|
|
747
|
+
nextResultStatus =
|
|
748
|
+
params.input.status === 'failed'
|
|
749
|
+
? 'failed'
|
|
750
|
+
: (params.input.resultStatus ?? (params.input.outputSummary?.trim() ? 'partial' : task.resultStatus))
|
|
751
|
+
nextPlan = await this.updatePlanStatus({
|
|
752
|
+
plan: activePlan,
|
|
753
|
+
status: 'blocked',
|
|
754
|
+
currentTaskId: task.id,
|
|
755
|
+
restartFromTaskId: task.id,
|
|
756
|
+
failureCount: nextFailureCount,
|
|
757
|
+
emittedBy: params.emittedBy,
|
|
758
|
+
})
|
|
759
|
+
if (params.input.status === 'failed') {
|
|
760
|
+
taskCompletedAt = now
|
|
761
|
+
}
|
|
762
|
+
message = params.input.status === 'failed' ? `Task "${task.title}" failed.` : `Task "${task.title}" is blocked.`
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const updatedTask = await this.updateTaskRecord({
|
|
766
|
+
task,
|
|
767
|
+
status: params.input.status,
|
|
768
|
+
resultStatus: nextResultStatus,
|
|
769
|
+
...(params.input.outputSummary !== undefined ? { outputSummary: params.input.outputSummary || null } : {}),
|
|
770
|
+
...(params.input.blockedReason !== undefined ? { blockedReason: params.input.blockedReason || null } : {}),
|
|
771
|
+
...(params.input.externalRef !== undefined ? { externalRef: params.input.externalRef || null } : {}),
|
|
772
|
+
...(params.input.status === 'in-progress' && taskStartedAt ? { startedAt: taskStartedAt } : {}),
|
|
773
|
+
...(taskCompletedAt !== undefined ? { completedAt: taskCompletedAt } : {}),
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
const eventType =
|
|
777
|
+
params.input.status === 'in-progress'
|
|
778
|
+
? 'task-started'
|
|
779
|
+
: params.input.status === 'completed'
|
|
780
|
+
? 'task-completed'
|
|
781
|
+
: params.input.status === 'blocked'
|
|
782
|
+
? 'task-blocked'
|
|
783
|
+
: params.input.status === 'failed'
|
|
784
|
+
? 'task-failed'
|
|
785
|
+
: 'task-skipped'
|
|
786
|
+
|
|
787
|
+
await this.emitEvent({
|
|
788
|
+
planId: nextPlan.id,
|
|
789
|
+
taskId: updatedTask.id,
|
|
790
|
+
eventType,
|
|
791
|
+
fromStatus: task.status,
|
|
792
|
+
toStatus: updatedTask.status,
|
|
793
|
+
message,
|
|
794
|
+
detail: {
|
|
795
|
+
resultStatus: updatedTask.resultStatus,
|
|
796
|
+
outputSummary: updatedTask.outputSummary,
|
|
797
|
+
blockedReason: updatedTask.blockedReason,
|
|
798
|
+
externalRef: updatedTask.externalRef,
|
|
799
|
+
failureCount: nextFailureCount,
|
|
800
|
+
},
|
|
801
|
+
emittedBy: params.emittedBy,
|
|
802
|
+
})
|
|
803
|
+
|
|
804
|
+
const serializable = await this.toSerializablePlan(nextPlan, { includeEvents: true })
|
|
805
|
+
return this.buildToolResult({
|
|
806
|
+
action: 'task-status-updated',
|
|
807
|
+
plan: serializable,
|
|
808
|
+
message:
|
|
809
|
+
nextFailureCount >= 2 && nextPlan.status === 'blocked'
|
|
810
|
+
? `${message} Failure budget reached; the plan is paused until it is restarted or replaced.`
|
|
811
|
+
: message,
|
|
812
|
+
changedTaskId: recordIdToString(updatedTask.id, TABLES.PLAN_TASK),
|
|
813
|
+
})
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async restartTask(params: {
|
|
817
|
+
workstreamId: RecordIdInput
|
|
818
|
+
emittedBy: string
|
|
819
|
+
input: RestartExecutionTaskArgs
|
|
820
|
+
}): Promise<ExecutionPlanToolResultData> {
|
|
821
|
+
const activePlan = await this.getActivePlanRecord(params.workstreamId)
|
|
822
|
+
if (!activePlan) {
|
|
823
|
+
throw new Error('No active execution plan exists for this workstream.')
|
|
824
|
+
}
|
|
825
|
+
if (recordIdToString(activePlan.id, TABLES.PLAN) !== params.input.planId) {
|
|
826
|
+
throw new Error('Only the active execution plan can restart tasks.')
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const tasks = await this.listPlanTasks(activePlan.id)
|
|
830
|
+
const targetTask = tasks.find((task) => recordIdToString(task.id, TABLES.PLAN_TASK) === params.input.taskId)
|
|
831
|
+
if (!targetTask) {
|
|
832
|
+
throw new Error('Execution plan task not found for restart.')
|
|
833
|
+
}
|
|
834
|
+
if (targetTask.status !== 'blocked' && targetTask.status !== 'failed') {
|
|
835
|
+
throw new Error('Only blocked or failed execution tasks can be restarted.')
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
for (const task of tasks) {
|
|
839
|
+
const shouldReset =
|
|
840
|
+
recordIdToString(task.id, TABLES.PLAN_TASK) === params.input.taskId ||
|
|
841
|
+
(params.input.resetDownstream && task.position > targetTask.position)
|
|
842
|
+
|
|
843
|
+
if (!shouldReset) continue
|
|
844
|
+
|
|
845
|
+
const nextStatus: ExecutionPlanTaskStatus = 'pending'
|
|
846
|
+
await this.updateTaskRecord({
|
|
847
|
+
task,
|
|
848
|
+
status: nextStatus,
|
|
849
|
+
resultStatus: 'not-started',
|
|
850
|
+
outputSummary: null,
|
|
851
|
+
blockedReason: null,
|
|
852
|
+
externalRef: null,
|
|
853
|
+
startedAt: null,
|
|
854
|
+
completedAt: null,
|
|
855
|
+
})
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const nextPlan = await this.updatePlanStatus({
|
|
859
|
+
plan: activePlan,
|
|
860
|
+
status: 'executing',
|
|
861
|
+
currentTaskId: targetTask.id,
|
|
862
|
+
restartFromTaskId: targetTask.id,
|
|
863
|
+
failureCount: 0,
|
|
864
|
+
completedAt: null,
|
|
865
|
+
...(activePlan.startedAt ? {} : { startedAt: new Date() }),
|
|
866
|
+
emittedBy: params.emittedBy,
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
await this.emitEvent({
|
|
870
|
+
planId: nextPlan.id,
|
|
871
|
+
taskId: targetTask.id,
|
|
872
|
+
eventType: 'task-restarted',
|
|
873
|
+
fromStatus: targetTask.status,
|
|
874
|
+
toStatus: 'pending',
|
|
875
|
+
message: `Restarted task "${targetTask.title}".`,
|
|
876
|
+
detail: { resetDownstream: params.input.resetDownstream },
|
|
877
|
+
emittedBy: params.emittedBy,
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
const serializable = await this.toSerializablePlan(nextPlan, { includeEvents: true })
|
|
881
|
+
return this.buildToolResult({
|
|
882
|
+
action: 'task-restarted',
|
|
883
|
+
plan: serializable,
|
|
884
|
+
changedTaskId: recordIdToString(targetTask.id, TABLES.PLAN_TASK),
|
|
885
|
+
message: `Restarted task "${targetTask.title}".`,
|
|
886
|
+
})
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
export const executionPlanService = new ExecutionPlanService()
|