@polpo-ai/core 0.2.4 → 0.3.0
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/LICENSE +21 -0
- package/dist/assessment-orchestrator.d.ts +124 -0
- package/dist/assessment-orchestrator.d.ts.map +1 -0
- package/dist/assessment-orchestrator.js +790 -0
- package/dist/assessment-orchestrator.js.map +1 -0
- package/dist/assessment-prompts.d.ts +45 -0
- package/dist/assessment-prompts.d.ts.map +1 -0
- package/dist/assessment-prompts.js +205 -0
- package/dist/assessment-prompts.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/mission-executor.d.ts +253 -0
- package/dist/mission-executor.d.ts.map +1 -0
- package/dist/mission-executor.js +1053 -0
- package/dist/mission-executor.js.map +1 -0
- package/dist/orchestrator-context.d.ts +11 -5
- package/dist/orchestrator-context.d.ts.map +1 -1
- package/dist/orchestrator-engine.d.ts +500 -0
- package/dist/orchestrator-engine.d.ts.map +1 -0
- package/dist/orchestrator-engine.js +454 -0
- package/dist/orchestrator-engine.js.map +1 -0
- package/dist/question-detector.d.ts +31 -0
- package/dist/question-detector.d.ts.map +1 -0
- package/dist/question-detector.js +65 -0
- package/dist/question-detector.js.map +1 -0
- package/dist/spawner.d.ts +43 -0
- package/dist/spawner.d.ts.map +1 -0
- package/dist/spawner.js +2 -0
- package/dist/spawner.js.map +1 -0
- package/dist/task-runner.d.ts +43 -0
- package/dist/task-runner.d.ts.map +1 -0
- package/dist/task-runner.js +487 -0
- package/dist/task-runner.js.map +1 -0
- package/package.json +34 -8
|
@@ -0,0 +1,1053 @@
|
|
|
1
|
+
import { sanitizeExpectations, parseMissionDocument } from "./schemas.js";
|
|
2
|
+
// ── In-memory fallback stores (no Node.js deps) ─────────────────────────
|
|
3
|
+
class InMemoryCheckpointStore {
|
|
4
|
+
state = { definitions: {}, active: {}, resumed: [] };
|
|
5
|
+
async load() {
|
|
6
|
+
return this.state;
|
|
7
|
+
}
|
|
8
|
+
async save(state) {
|
|
9
|
+
this.state = state;
|
|
10
|
+
}
|
|
11
|
+
async removeGroup(state, group) {
|
|
12
|
+
delete state.definitions[group];
|
|
13
|
+
for (const key of Object.keys(state.active)) {
|
|
14
|
+
if (key.startsWith(group + ":"))
|
|
15
|
+
delete state.active[key];
|
|
16
|
+
}
|
|
17
|
+
state.resumed = state.resumed.filter(k => !k.startsWith(group + ":"));
|
|
18
|
+
return state;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
class InMemoryDelayStore {
|
|
22
|
+
state = { definitions: {}, active: {}, expired: [] };
|
|
23
|
+
async load() {
|
|
24
|
+
return this.state;
|
|
25
|
+
}
|
|
26
|
+
async save(state) {
|
|
27
|
+
this.state = state;
|
|
28
|
+
}
|
|
29
|
+
async removeGroup(state, group) {
|
|
30
|
+
delete state.definitions[group];
|
|
31
|
+
for (const key of Object.keys(state.active)) {
|
|
32
|
+
if (key.startsWith(group + ":"))
|
|
33
|
+
delete state.active[key];
|
|
34
|
+
}
|
|
35
|
+
state.expired = state.expired.filter(k => !k.startsWith(group + ":"));
|
|
36
|
+
return state;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Mission CRUD + execution + resume + group lifecycle.
|
|
41
|
+
*/
|
|
42
|
+
export class MissionExecutor {
|
|
43
|
+
ctx;
|
|
44
|
+
taskMgr;
|
|
45
|
+
agentMgr;
|
|
46
|
+
cleanedGroups = new Set();
|
|
47
|
+
/** Quality gates parsed from mission documents, keyed by mission group name */
|
|
48
|
+
gatesByGroup = new Map();
|
|
49
|
+
/** Persistent checkpoint store — survives server restarts */
|
|
50
|
+
cpStore;
|
|
51
|
+
/** In-memory mirror of persisted checkpoint state (synced on every mutation) */
|
|
52
|
+
cpState;
|
|
53
|
+
/** Optional quality controller — set by orchestrator after init */
|
|
54
|
+
qualityCtrl;
|
|
55
|
+
/** Optional notification router — for checkpoint notification rules */
|
|
56
|
+
notificationRouter;
|
|
57
|
+
/** Track which checkpoint notification rules have been registered (by cpKey) */
|
|
58
|
+
registeredCheckpointRules = new Set();
|
|
59
|
+
/** Persistent delay store — survives server restarts */
|
|
60
|
+
delayStore;
|
|
61
|
+
/** In-memory mirror of persisted delay state (synced on every mutation) */
|
|
62
|
+
delayState;
|
|
63
|
+
/** Track which delay notification rules have been registered (by delayKey) */
|
|
64
|
+
registeredDelayRules = new Set();
|
|
65
|
+
/** Track the pre-execution status for scheduled/recurring missions (by group name).
|
|
66
|
+
* When a mission completes/fails, this determines whether to return to scheduled/recurring. */
|
|
67
|
+
scheduledOrigin = new Map();
|
|
68
|
+
constructor(ctx, taskMgr, agentMgr) {
|
|
69
|
+
this.ctx = ctx;
|
|
70
|
+
this.taskMgr = taskMgr;
|
|
71
|
+
this.agentMgr = agentMgr;
|
|
72
|
+
this.cpStore = ctx.checkpointStore ?? new InMemoryCheckpointStore();
|
|
73
|
+
this.delayStore = ctx.delayStore ?? new InMemoryDelayStore();
|
|
74
|
+
// Rebuild cleanedGroups from persisted task state — groups where ALL tasks
|
|
75
|
+
// are already terminal don't need to be re-processed after a server restart.
|
|
76
|
+
// Constructor cannot be async — expose ready promise for callers to await.
|
|
77
|
+
this.ready = this.initStoresAndRebuild();
|
|
78
|
+
}
|
|
79
|
+
/** Resolves when async store loading and group rebuild are complete. */
|
|
80
|
+
ready;
|
|
81
|
+
/** Async init: load checkpoint/delay state and rebuild cleanedGroups. */
|
|
82
|
+
async initStoresAndRebuild() {
|
|
83
|
+
this.cpState = await this.cpStore.load();
|
|
84
|
+
this.delayState = await this.delayStore.load();
|
|
85
|
+
await this.rebuildCleanedGroups();
|
|
86
|
+
}
|
|
87
|
+
/** Async init: rebuild cleanedGroups from persisted task state. */
|
|
88
|
+
async rebuildCleanedGroups() {
|
|
89
|
+
const allTasks = await this.ctx.registry.getAllTasks();
|
|
90
|
+
const groups = new Set();
|
|
91
|
+
for (const t of allTasks) {
|
|
92
|
+
if (t.group)
|
|
93
|
+
groups.add(t.group);
|
|
94
|
+
}
|
|
95
|
+
for (const group of groups) {
|
|
96
|
+
const groupTasks = allTasks.filter(t => t.group === group);
|
|
97
|
+
if (groupTasks.every(t => t.status === "done" || t.status === "failed")) {
|
|
98
|
+
this.cleanedGroups.add(group);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Set the quality controller instance (called by Orchestrator after init). */
|
|
103
|
+
setQualityController(ctrl) {
|
|
104
|
+
this.qualityCtrl = ctrl;
|
|
105
|
+
}
|
|
106
|
+
/** Set the notification router — enables per-checkpoint channel routing. */
|
|
107
|
+
setNotificationRouter(router) {
|
|
108
|
+
this.notificationRouter = router;
|
|
109
|
+
}
|
|
110
|
+
/** Get quality gates for a mission group. Returns empty array if none defined. */
|
|
111
|
+
getQualityGates(group) {
|
|
112
|
+
return this.gatesByGroup.get(group) ?? [];
|
|
113
|
+
}
|
|
114
|
+
/** Get checkpoints for a mission group. Returns empty array if none defined. */
|
|
115
|
+
getCheckpoints(group) {
|
|
116
|
+
return this.cpState.definitions[group] ?? [];
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Check if a task is blocked by an active (unresumed) checkpoint.
|
|
120
|
+
* Returns the blocking checkpoint if found, undefined if the task can proceed.
|
|
121
|
+
*/
|
|
122
|
+
async getBlockingCheckpoint(group, taskTitle, taskId, tasks) {
|
|
123
|
+
const checkpoints = this.cpState.definitions[group];
|
|
124
|
+
if (!checkpoints)
|
|
125
|
+
return undefined;
|
|
126
|
+
for (const cp of checkpoints) {
|
|
127
|
+
// Task must be in blocksTasks
|
|
128
|
+
if (!cp.blocksTasks.includes(taskTitle) && !cp.blocksTasks.includes(taskId)) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const cpKey = `${group}:${cp.name}`;
|
|
132
|
+
// Already resumed — don't block
|
|
133
|
+
if (this.cpState.resumed.includes(cpKey))
|
|
134
|
+
continue;
|
|
135
|
+
// Check if all afterTasks are done
|
|
136
|
+
const afterTasks = tasks.filter(t => cp.afterTasks.includes(t.title) || cp.afterTasks.includes(t.id));
|
|
137
|
+
const allDone = afterTasks.length >= cp.afterTasks.length &&
|
|
138
|
+
afterTasks.every(t => t.status === "done" || t.status === "failed");
|
|
139
|
+
if (!allDone) {
|
|
140
|
+
// afterTasks not finished yet — checkpoint not reached, don't block (deps will block naturally)
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// Checkpoint reached — activate it if not already active
|
|
144
|
+
if (!this.cpState.active[cpKey]) {
|
|
145
|
+
const reachedAt = new Date().toISOString();
|
|
146
|
+
this.cpState.active[cpKey] = { checkpoint: cp, reachedAt };
|
|
147
|
+
await this.cpStore.save(this.cpState);
|
|
148
|
+
// Pause the mission — use task.missionId when available
|
|
149
|
+
const taskMissionId = tasks.find(t => t.group === group && t.missionId)?.missionId;
|
|
150
|
+
const mission = taskMissionId
|
|
151
|
+
? await this.ctx.registry.getMission?.(taskMissionId)
|
|
152
|
+
: await this.ctx.registry.getMissionByName?.(group);
|
|
153
|
+
if (mission && mission.status === "active") {
|
|
154
|
+
await this.ctx.registry.updateMission?.(mission.id, { status: "paused" });
|
|
155
|
+
}
|
|
156
|
+
// Register notification rules for this checkpoint's channels
|
|
157
|
+
this.ensureCheckpointNotificationRules(cpKey, cp);
|
|
158
|
+
// Emit event (picked up by notification router if rules are configured)
|
|
159
|
+
this.ctx.emitter.emit("checkpoint:reached", {
|
|
160
|
+
missionId: mission?.id,
|
|
161
|
+
group,
|
|
162
|
+
checkpointName: cp.name,
|
|
163
|
+
message: cp.message,
|
|
164
|
+
afterTasks: cp.afterTasks,
|
|
165
|
+
blocksTasks: cp.blocksTasks,
|
|
166
|
+
reachedAt,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
// Return the blocking checkpoint
|
|
170
|
+
return this.cpState.active[cpKey];
|
|
171
|
+
}
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Resume a checkpoint, unblocking its blocksTasks.
|
|
176
|
+
* Returns true if the checkpoint was active and is now resumed, false if not found.
|
|
177
|
+
*/
|
|
178
|
+
async resumeCheckpoint(group, checkpointName) {
|
|
179
|
+
const cpKey = `${group}:${checkpointName}`;
|
|
180
|
+
const active = this.cpState.active[cpKey];
|
|
181
|
+
if (!active)
|
|
182
|
+
return false;
|
|
183
|
+
this.cpState.resumed.push(cpKey);
|
|
184
|
+
delete this.cpState.active[cpKey];
|
|
185
|
+
await this.cpStore.save(this.cpState);
|
|
186
|
+
// Un-pause the mission (back to active) — resolve via missionId from tasks
|
|
187
|
+
const groupTasks = (await this.ctx.registry.getAllTasks()).filter(t => t.group === group);
|
|
188
|
+
const mission = await this.resolveMissionForGroup(groupTasks, group);
|
|
189
|
+
if (mission && mission.status === "paused") {
|
|
190
|
+
await this.ctx.registry.updateMission?.(mission.id, { status: "active" });
|
|
191
|
+
}
|
|
192
|
+
this.ctx.emitter.emit("checkpoint:resumed", {
|
|
193
|
+
missionId: mission?.id,
|
|
194
|
+
group,
|
|
195
|
+
checkpointName,
|
|
196
|
+
});
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
/** Get all active (unresumed) checkpoints across all mission groups. */
|
|
200
|
+
getActiveCheckpoints() {
|
|
201
|
+
const result = [];
|
|
202
|
+
for (const [cpKey, data] of Object.entries(this.cpState.active)) {
|
|
203
|
+
const [group, ...nameParts] = cpKey.split(":");
|
|
204
|
+
const checkpointName = nameParts.join(":");
|
|
205
|
+
result.push({ group, checkpointName, checkpoint: data.checkpoint, reachedAt: data.reachedAt });
|
|
206
|
+
}
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
/** Register dynamic notification rules for a checkpoint's notifyChannels (once per checkpoint). */
|
|
210
|
+
ensureCheckpointNotificationRules(cpKey, cp) {
|
|
211
|
+
if (!this.notificationRouter)
|
|
212
|
+
return;
|
|
213
|
+
if (!cp.notifyChannels || cp.notifyChannels.length === 0)
|
|
214
|
+
return;
|
|
215
|
+
if (this.registeredCheckpointRules.has(cpKey))
|
|
216
|
+
return;
|
|
217
|
+
this.registeredCheckpointRules.add(cpKey);
|
|
218
|
+
// Rule for checkpoint reached
|
|
219
|
+
this.notificationRouter.addRule({
|
|
220
|
+
id: `checkpoint-reached-${cpKey}`,
|
|
221
|
+
name: `Checkpoint "${cp.name}" Reached (auto-registered)`,
|
|
222
|
+
events: ["checkpoint:reached"],
|
|
223
|
+
condition: { field: "checkpointName", op: "==", value: cp.name },
|
|
224
|
+
channels: cp.notifyChannels,
|
|
225
|
+
severity: "warning",
|
|
226
|
+
});
|
|
227
|
+
// Rule for checkpoint resumed
|
|
228
|
+
this.notificationRouter.addRule({
|
|
229
|
+
id: `checkpoint-resumed-${cpKey}`,
|
|
230
|
+
name: `Checkpoint "${cp.name}" Resumed (auto-registered)`,
|
|
231
|
+
events: ["checkpoint:resumed"],
|
|
232
|
+
condition: { field: "checkpointName", op: "==", value: cp.name },
|
|
233
|
+
channels: cp.notifyChannels,
|
|
234
|
+
severity: "info",
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
// ─── Delay runtime ──────────────────────────────────
|
|
238
|
+
/** Get delays for a mission group. Returns empty array if none defined. */
|
|
239
|
+
getDelays(group) {
|
|
240
|
+
return this.delayState.definitions[group] ?? [];
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Check if a task is blocked by an active (unexpired) delay.
|
|
244
|
+
* If afterTasks are all done and the delay hasn't started yet, starts the timer.
|
|
245
|
+
* If the timer has expired, marks the delay as expired and unblocks.
|
|
246
|
+
* Returns the blocking delay if found, undefined if the task can proceed.
|
|
247
|
+
*/
|
|
248
|
+
async getBlockingDelay(group, taskTitle, taskId, tasks) {
|
|
249
|
+
const delays = this.delayState.definitions[group];
|
|
250
|
+
if (!delays)
|
|
251
|
+
return undefined;
|
|
252
|
+
for (const dl of delays) {
|
|
253
|
+
// Task must be in blocksTasks
|
|
254
|
+
if (!dl.blocksTasks.includes(taskTitle) && !dl.blocksTasks.includes(taskId)) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const dlKey = `${group}:${dl.name}`;
|
|
258
|
+
// Already expired — don't block
|
|
259
|
+
if (this.delayState.expired.includes(dlKey))
|
|
260
|
+
continue;
|
|
261
|
+
// Check if all afterTasks are done
|
|
262
|
+
const afterTasks = tasks.filter(t => dl.afterTasks.includes(t.title) || dl.afterTasks.includes(t.id));
|
|
263
|
+
const allDone = afterTasks.length >= dl.afterTasks.length &&
|
|
264
|
+
afterTasks.every(t => t.status === "done" || t.status === "failed");
|
|
265
|
+
if (!allDone) {
|
|
266
|
+
// afterTasks not finished yet — delay not triggered, don't block (deps will block naturally)
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
// Delay triggered — activate timer if not already active
|
|
270
|
+
if (!this.delayState.active[dlKey]) {
|
|
271
|
+
const startedAt = new Date().toISOString();
|
|
272
|
+
const durationMs = parseISO8601Duration(dl.duration);
|
|
273
|
+
const expiresAt = new Date(Date.now() + durationMs).toISOString();
|
|
274
|
+
this.delayState.active[dlKey] = { delay: dl, startedAt, expiresAt };
|
|
275
|
+
await this.delayStore.save(this.delayState);
|
|
276
|
+
// Register notification rules for this delay's channels
|
|
277
|
+
this.ensureDelayNotificationRules(dlKey, dl);
|
|
278
|
+
// Emit event
|
|
279
|
+
const mission = await this.resolveMissionForGroupByName(group);
|
|
280
|
+
this.ctx.emitter.emit("delay:started", {
|
|
281
|
+
missionId: mission?.id,
|
|
282
|
+
group,
|
|
283
|
+
delayName: dl.name,
|
|
284
|
+
duration: dl.duration,
|
|
285
|
+
message: dl.message,
|
|
286
|
+
afterTasks: dl.afterTasks,
|
|
287
|
+
blocksTasks: dl.blocksTasks,
|
|
288
|
+
startedAt,
|
|
289
|
+
expiresAt,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
// Check if the timer has expired
|
|
293
|
+
const active = this.delayState.active[dlKey];
|
|
294
|
+
if (new Date(active.expiresAt).getTime() <= Date.now()) {
|
|
295
|
+
// Timer expired — mark as expired and unblock
|
|
296
|
+
this.delayState.expired.push(dlKey);
|
|
297
|
+
delete this.delayState.active[dlKey];
|
|
298
|
+
await this.delayStore.save(this.delayState);
|
|
299
|
+
const mission = await this.resolveMissionForGroupByName(group);
|
|
300
|
+
this.ctx.emitter.emit("delay:expired", {
|
|
301
|
+
missionId: mission?.id,
|
|
302
|
+
group,
|
|
303
|
+
delayName: dl.name,
|
|
304
|
+
});
|
|
305
|
+
continue; // Unblocked — check next delay
|
|
306
|
+
}
|
|
307
|
+
// Still waiting — return the blocking delay
|
|
308
|
+
return active;
|
|
309
|
+
}
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
/** Get all active (unexpired) delays across all mission groups. */
|
|
313
|
+
getActiveDelays() {
|
|
314
|
+
const result = [];
|
|
315
|
+
for (const [dlKey, data] of Object.entries(this.delayState.active)) {
|
|
316
|
+
const [group, ...nameParts] = dlKey.split(":");
|
|
317
|
+
const delayName = nameParts.join(":");
|
|
318
|
+
result.push({ group, delayName, delay: data.delay, startedAt: data.startedAt, expiresAt: data.expiresAt });
|
|
319
|
+
}
|
|
320
|
+
return result;
|
|
321
|
+
}
|
|
322
|
+
/** Register dynamic notification rules for a delay's notifyChannels (once per delay). */
|
|
323
|
+
ensureDelayNotificationRules(dlKey, dl) {
|
|
324
|
+
if (!this.notificationRouter)
|
|
325
|
+
return;
|
|
326
|
+
if (!dl.notifyChannels || dl.notifyChannels.length === 0)
|
|
327
|
+
return;
|
|
328
|
+
if (this.registeredDelayRules.has(dlKey))
|
|
329
|
+
return;
|
|
330
|
+
this.registeredDelayRules.add(dlKey);
|
|
331
|
+
// Rule for delay started
|
|
332
|
+
this.notificationRouter.addRule({
|
|
333
|
+
id: `delay-started-${dlKey}`,
|
|
334
|
+
name: `Delay "${dl.name}" Started (auto-registered)`,
|
|
335
|
+
events: ["delay:started"],
|
|
336
|
+
condition: { field: "delayName", op: "==", value: dl.name },
|
|
337
|
+
channels: dl.notifyChannels,
|
|
338
|
+
severity: "info",
|
|
339
|
+
});
|
|
340
|
+
// Rule for delay expired
|
|
341
|
+
this.notificationRouter.addRule({
|
|
342
|
+
id: `delay-expired-${dlKey}`,
|
|
343
|
+
name: `Delay "${dl.name}" Expired (auto-registered)`,
|
|
344
|
+
events: ["delay:expired"],
|
|
345
|
+
condition: { field: "delayName", op: "==", value: dl.name },
|
|
346
|
+
channels: dl.notifyChannels,
|
|
347
|
+
severity: "info",
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
/** Resolve a mission by group name (helper for delay/checkpoint events). */
|
|
351
|
+
async resolveMissionForGroupByName(group) {
|
|
352
|
+
const groupTasks = (await this.ctx.registry.getAllTasks()).filter(t => t.group === group);
|
|
353
|
+
return this.resolveMissionForGroup(groupTasks, group);
|
|
354
|
+
}
|
|
355
|
+
async saveMission(opts) {
|
|
356
|
+
if (!this.ctx.registry.saveMission)
|
|
357
|
+
throw new Error("Store does not support missions");
|
|
358
|
+
const name = opts.name ?? (await this.ctx.registry.nextMissionName?.()) ?? `mission-${Date.now()}`;
|
|
359
|
+
const mission = await this.ctx.registry.saveMission({
|
|
360
|
+
name,
|
|
361
|
+
data: opts.data,
|
|
362
|
+
prompt: opts.prompt,
|
|
363
|
+
status: opts.status ?? "draft",
|
|
364
|
+
notifications: opts.notifications,
|
|
365
|
+
});
|
|
366
|
+
this.ctx.emitter.emit("mission:saved", { missionId: mission.id, name: mission.name, status: mission.status });
|
|
367
|
+
return mission;
|
|
368
|
+
}
|
|
369
|
+
async getMission(missionId) {
|
|
370
|
+
return this.ctx.registry.getMission?.(missionId);
|
|
371
|
+
}
|
|
372
|
+
async getMissionByName(name) {
|
|
373
|
+
return this.ctx.registry.getMissionByName?.(name);
|
|
374
|
+
}
|
|
375
|
+
async getAllMissions() {
|
|
376
|
+
return (await this.ctx.registry.getAllMissions?.()) ?? [];
|
|
377
|
+
}
|
|
378
|
+
async updateMission(missionId, updates) {
|
|
379
|
+
if (!this.ctx.registry.updateMission)
|
|
380
|
+
throw new Error("Store does not support missions");
|
|
381
|
+
return this.ctx.registry.updateMission(missionId, updates);
|
|
382
|
+
}
|
|
383
|
+
async deleteMission(missionId) {
|
|
384
|
+
if (!this.ctx.registry.deleteMission)
|
|
385
|
+
throw new Error("Store does not support missions");
|
|
386
|
+
const mission = await this.getMission(missionId);
|
|
387
|
+
if (!mission)
|
|
388
|
+
return false;
|
|
389
|
+
// ── Cascade cleanup ──────────────────────────────────
|
|
390
|
+
// 1. Kill running processes and remove all tasks belonging to this mission
|
|
391
|
+
const missionGroup = mission.name;
|
|
392
|
+
const deletedTasks = await this.taskMgr.clearTasks(t => t.missionId === missionId || t.group === missionGroup);
|
|
393
|
+
// 2. Clean up volatile agents registered for this mission group
|
|
394
|
+
await this.agentMgr.cleanupVolatileAgents(missionGroup);
|
|
395
|
+
// 3. Clean up in-memory quality gates
|
|
396
|
+
this.gatesByGroup.delete(missionGroup);
|
|
397
|
+
// Also clean numbered groups for recurring missions (e.g. "mission-1 #2", "mission-1 #3")
|
|
398
|
+
for (const key of this.gatesByGroup.keys()) {
|
|
399
|
+
if (key.startsWith(missionGroup + " #"))
|
|
400
|
+
this.gatesByGroup.delete(key);
|
|
401
|
+
}
|
|
402
|
+
// 4. Clean up persisted checkpoints
|
|
403
|
+
this.cpState = await this.cpStore.removeGroup(this.cpState, missionGroup);
|
|
404
|
+
for (const key of Object.keys(this.cpState.definitions)) {
|
|
405
|
+
if (key.startsWith(missionGroup + " #")) {
|
|
406
|
+
this.cpState = await this.cpStore.removeGroup(this.cpState, key);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// 4b. Clean up persisted delays
|
|
410
|
+
this.delayState = await this.delayStore.removeGroup(this.delayState, missionGroup);
|
|
411
|
+
for (const key of Object.keys(this.delayState.definitions)) {
|
|
412
|
+
if (key.startsWith(missionGroup + " #")) {
|
|
413
|
+
this.delayState = await this.delayStore.removeGroup(this.delayState, key);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// 5. Clean up scheduled origin cache
|
|
417
|
+
this.scheduledOrigin.delete(missionGroup);
|
|
418
|
+
for (const key of this.scheduledOrigin.keys()) {
|
|
419
|
+
if (key.startsWith(missionGroup + " #"))
|
|
420
|
+
this.scheduledOrigin.delete(key);
|
|
421
|
+
}
|
|
422
|
+
// 6. Allow re-cleanup if the group is ever recreated
|
|
423
|
+
this.cleanedGroups.delete(missionGroup);
|
|
424
|
+
for (const key of this.cleanedGroups) {
|
|
425
|
+
if (key.startsWith(missionGroup + " #"))
|
|
426
|
+
this.cleanedGroups.delete(key);
|
|
427
|
+
}
|
|
428
|
+
// ── Delete the mission record ────────────────────────
|
|
429
|
+
const result = await this.ctx.registry.deleteMission(missionId);
|
|
430
|
+
if (result) {
|
|
431
|
+
this.ctx.emitter.emit("mission:deleted", { missionId, deletedTasks });
|
|
432
|
+
}
|
|
433
|
+
return result;
|
|
434
|
+
}
|
|
435
|
+
async getResumableMissions() {
|
|
436
|
+
const missions = await this.getAllMissions();
|
|
437
|
+
const state = await this.ctx.registry.getState();
|
|
438
|
+
return missions.filter(m => {
|
|
439
|
+
// Non-resumable statuses: draft (never executed), scheduled/recurring (scheduler handles),
|
|
440
|
+
// completed (done), cancelled (aborted)
|
|
441
|
+
if (m.status === "draft" || m.status === "scheduled" || m.status === "recurring" ||
|
|
442
|
+
m.status === "completed" || m.status === "cancelled")
|
|
443
|
+
return false;
|
|
444
|
+
const tasks = state.tasks.filter(t => t.group === m.name);
|
|
445
|
+
if (tasks.length === 0)
|
|
446
|
+
return false;
|
|
447
|
+
return tasks.some(t => t.status === "pending" || t.status === "failed");
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
async resumeMission(missionId, opts) {
|
|
451
|
+
const mission = await this.getMission(missionId);
|
|
452
|
+
if (!mission)
|
|
453
|
+
throw new Error("Mission not found");
|
|
454
|
+
// Re-register volatile agents if they were cleaned up
|
|
455
|
+
this.cleanedGroups.delete(mission.name);
|
|
456
|
+
const enableVolatile = this.ctx.config.settings.enableVolatileTeams !== false;
|
|
457
|
+
if (enableVolatile && mission.data) {
|
|
458
|
+
try {
|
|
459
|
+
const doc = JSON.parse(mission.data);
|
|
460
|
+
if (doc?.team && Array.isArray(doc.team)) {
|
|
461
|
+
for (const a of doc.team) {
|
|
462
|
+
if (!a.name)
|
|
463
|
+
continue;
|
|
464
|
+
const { name, ...rest } = a;
|
|
465
|
+
await this.agentMgr.addVolatileAgent({ name, ...rest }, mission.name);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
this.ctx.emitter.emit("log", { level: "warn", message: `Failed to re-register volatile agents for ${mission.name}: ${err instanceof Error ? err.message : String(err)}` });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const state = await this.ctx.registry.getState();
|
|
474
|
+
const tasks = state.tasks.filter(t => t.group === mission.name);
|
|
475
|
+
const failedTasks = tasks.filter(t => t.status === "failed");
|
|
476
|
+
const pendingTasks = tasks.filter(t => t.status === "pending");
|
|
477
|
+
let retried = 0;
|
|
478
|
+
if (opts?.retryFailed) {
|
|
479
|
+
for (const task of failedTasks) {
|
|
480
|
+
try {
|
|
481
|
+
await this.taskMgr.retryTask(task.id);
|
|
482
|
+
retried++;
|
|
483
|
+
}
|
|
484
|
+
catch { /* no retries left — skip */
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (mission.status === "failed") {
|
|
489
|
+
await this.updateMission(missionId, { status: "active" });
|
|
490
|
+
}
|
|
491
|
+
this.ctx.emitter.emit("mission:resumed", { missionId, name: mission.name, retried, pending: pendingTasks.length });
|
|
492
|
+
return { retried, pending: pendingTasks.length };
|
|
493
|
+
}
|
|
494
|
+
async executeMission(missionId) {
|
|
495
|
+
const mission = await this.ctx.registry.getMission?.(missionId);
|
|
496
|
+
if (!mission)
|
|
497
|
+
throw new Error("Mission not found");
|
|
498
|
+
const executableStates = ["draft", "scheduled", "recurring"];
|
|
499
|
+
if (!executableStates.includes(mission.status)) {
|
|
500
|
+
throw new Error(`Cannot execute mission in "${mission.status}" state (must be "draft", "scheduled", or "recurring")`);
|
|
501
|
+
}
|
|
502
|
+
// Remember whether this is a scheduled/recurring mission so we can restore status after completion
|
|
503
|
+
const scheduledStatus = mission.status === "scheduled" || mission.status === "recurring" ? mission.status : undefined;
|
|
504
|
+
// Increment execution count (tracks how many times this mission has run — useful for recurring)
|
|
505
|
+
const runNumber = (mission.executionCount ?? 0) + 1;
|
|
506
|
+
await this.ctx.registry.updateMission?.(missionId, { executionCount: runNumber });
|
|
507
|
+
// Validate mission document through Zod schema — throws with clear error on invalid shape
|
|
508
|
+
const raw = JSON.parse(mission.data);
|
|
509
|
+
const doc = parseMissionDocument(raw);
|
|
510
|
+
// For recurring/scheduled missions with multiple runs, disambiguate the group with a run number
|
|
511
|
+
const group = runNumber > 1 ? `${mission.name} #${runNumber}` : mission.name;
|
|
512
|
+
// Run before:mission:execute hook
|
|
513
|
+
const hookResult = this.ctx.hooks.runBeforeSync("mission:execute", {
|
|
514
|
+
missionId,
|
|
515
|
+
mission,
|
|
516
|
+
taskCount: doc.tasks.length,
|
|
517
|
+
});
|
|
518
|
+
if (hookResult.cancelled) {
|
|
519
|
+
throw new Error(`Mission execution blocked by hook: ${hookResult.cancelReason ?? "no reason"}`);
|
|
520
|
+
}
|
|
521
|
+
// Register volatile agents from the mission's team section
|
|
522
|
+
const enableVolatile = this.ctx.config.settings.enableVolatileTeams !== false;
|
|
523
|
+
if (enableVolatile && doc.team && Array.isArray(doc.team)) {
|
|
524
|
+
for (const a of doc.team) {
|
|
525
|
+
if (!a.name)
|
|
526
|
+
continue;
|
|
527
|
+
const { name, ...rest } = a;
|
|
528
|
+
await this.agentMgr.addVolatileAgent({ name, ...rest }, group);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
// Validate API keys for all agents referenced in the mission
|
|
532
|
+
const allAgents = await this.agentMgr.getAgents();
|
|
533
|
+
const referencedModels = [];
|
|
534
|
+
for (const t of doc.tasks) {
|
|
535
|
+
const agentName = t.assignTo || allAgents[0]?.name;
|
|
536
|
+
const agent = allAgents.find(a => a.name === agentName);
|
|
537
|
+
if (agent?.model) {
|
|
538
|
+
referencedModels.push(agent.model);
|
|
539
|
+
}
|
|
540
|
+
else if (!agent) {
|
|
541
|
+
throw new Error(`Mission references agent "${agentName}" (task "${t.title}") but no such agent exists. ` +
|
|
542
|
+
`Available agents: ${allAgents.map(a => a.name).join(", ")}`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (referencedModels.length > 0 && this.ctx.validateProviderKeys) {
|
|
546
|
+
const missing = this.ctx.validateProviderKeys(referencedModels);
|
|
547
|
+
if (missing.length > 0) {
|
|
548
|
+
const details = missing
|
|
549
|
+
.map(m => `${m.provider} (model: ${m.modelSpec})`)
|
|
550
|
+
.join(", ");
|
|
551
|
+
throw new Error(`Missing API keys for providers: ${details}. ` +
|
|
552
|
+
`Set the corresponding environment variables or add them to polpo.json providers section.`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
// Create tasks with dependency resolution
|
|
556
|
+
const titleToId = new Map();
|
|
557
|
+
const tasks = [];
|
|
558
|
+
for (const t of doc.tasks) {
|
|
559
|
+
const deps = (t.dependsOn || [])
|
|
560
|
+
.map((title) => titleToId.get(title))
|
|
561
|
+
.filter((id) => !!id);
|
|
562
|
+
// Validate expectations through Zod schemas
|
|
563
|
+
let expectations = [];
|
|
564
|
+
if (t.expectations && Array.isArray(t.expectations) && t.expectations.length > 0) {
|
|
565
|
+
const { valid, warnings } = sanitizeExpectations(t.expectations);
|
|
566
|
+
expectations = valid;
|
|
567
|
+
for (const w of warnings) {
|
|
568
|
+
this.ctx.emitter.emit("log", { level: "warn", message: `Mission task "${t.title}": ${w}` });
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
const task = await this.taskMgr.addTask({
|
|
572
|
+
title: t.title,
|
|
573
|
+
description: t.description || t.title,
|
|
574
|
+
assignTo: t.assignTo || (await this.agentMgr.getAgents())[0]?.name || "default",
|
|
575
|
+
dependsOn: deps,
|
|
576
|
+
expectations,
|
|
577
|
+
expectedOutcomes: t.expectedOutcomes,
|
|
578
|
+
group,
|
|
579
|
+
missionId,
|
|
580
|
+
maxDuration: t.maxDuration,
|
|
581
|
+
retryPolicy: t.retryPolicy,
|
|
582
|
+
notifications: t.notifications,
|
|
583
|
+
sideEffects: t.sideEffects,
|
|
584
|
+
});
|
|
585
|
+
titleToId.set(t.title, task.id);
|
|
586
|
+
tasks.push(task);
|
|
587
|
+
}
|
|
588
|
+
// Store quality gates (in-memory) and checkpoints (persisted to disk)
|
|
589
|
+
if (doc.qualityGates && doc.qualityGates.length > 0) {
|
|
590
|
+
this.gatesByGroup.set(group, doc.qualityGates);
|
|
591
|
+
}
|
|
592
|
+
if (doc.checkpoints && doc.checkpoints.length > 0) {
|
|
593
|
+
this.cpState.definitions[group] = doc.checkpoints;
|
|
594
|
+
await this.cpStore.save(this.cpState);
|
|
595
|
+
}
|
|
596
|
+
if (doc.delays && doc.delays.length > 0) {
|
|
597
|
+
this.delayState.definitions[group] = doc.delays;
|
|
598
|
+
await this.delayStore.save(this.delayState);
|
|
599
|
+
}
|
|
600
|
+
// Track scheduled origin so we know where to return after completion
|
|
601
|
+
if (scheduledStatus) {
|
|
602
|
+
this.scheduledOrigin.set(group, scheduledStatus);
|
|
603
|
+
}
|
|
604
|
+
// Persist mission-level notifications from document onto the Mission record
|
|
605
|
+
if (doc.notifications) {
|
|
606
|
+
await this.ctx.registry.updateMission?.(missionId, { status: "active", notifications: doc.notifications });
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
// Mark mission as active
|
|
610
|
+
await this.ctx.registry.updateMission?.(missionId, { status: "active" });
|
|
611
|
+
}
|
|
612
|
+
this.ctx.emitter.emit("mission:executed", { missionId, group, taskCount: tasks.length });
|
|
613
|
+
return { tasks, group };
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Resolve the Mission for a group of tasks.
|
|
617
|
+
* Uses task.missionId (direct FK) when available, falls back to getMissionByName
|
|
618
|
+
* for legacy tasks that pre-date the missionId field.
|
|
619
|
+
*/
|
|
620
|
+
async resolveMissionForGroup(groupTasks, group) {
|
|
621
|
+
// Prefer the direct ID reference from any task in the group
|
|
622
|
+
const mid = groupTasks.find(t => t.missionId)?.missionId;
|
|
623
|
+
if (mid)
|
|
624
|
+
return this.ctx.registry.getMission?.(mid);
|
|
625
|
+
// Fallback: strip run-number suffix (e.g. "Mission #3" → "Mission") for legacy compat
|
|
626
|
+
return this.ctx.registry.getMissionByName?.(group.replace(/ #\d+$/, ""));
|
|
627
|
+
}
|
|
628
|
+
/** Check if any mission groups have all tasks terminal, and clean up their volatile agents */
|
|
629
|
+
async cleanupCompletedGroups(tasks) {
|
|
630
|
+
const groups = new Set();
|
|
631
|
+
for (const t of tasks) {
|
|
632
|
+
if (t.group)
|
|
633
|
+
groups.add(t.group);
|
|
634
|
+
}
|
|
635
|
+
for (const group of groups) {
|
|
636
|
+
const groupTasks = tasks.filter(t => t.group === group);
|
|
637
|
+
const allTerminal = groupTasks.every(t => t.status === "done" || t.status === "failed");
|
|
638
|
+
// If tasks went back to non-terminal (e.g. individual retry via retryTask),
|
|
639
|
+
// clear the cleaned flag so the group will be re-evaluated when done again.
|
|
640
|
+
if (!allTerminal && this.cleanedGroups.has(group)) {
|
|
641
|
+
this.cleanedGroups.delete(group);
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
if (this.cleanedGroups.has(group))
|
|
645
|
+
continue;
|
|
646
|
+
if (!allTerminal)
|
|
647
|
+
continue;
|
|
648
|
+
const cleanupPolicy = this.ctx.config.settings.volatileCleanup ?? "on_complete";
|
|
649
|
+
if (cleanupPolicy === "on_complete") {
|
|
650
|
+
await this.agentMgr.cleanupVolatileAgents(group);
|
|
651
|
+
}
|
|
652
|
+
this.cleanedGroups.add(group);
|
|
653
|
+
// Auto-update mission status
|
|
654
|
+
const mission = await this.resolveMissionForGroup(groupTasks, group);
|
|
655
|
+
if (mission && mission.status === "active") {
|
|
656
|
+
let allDone = groupTasks.every(t => t.status === "done");
|
|
657
|
+
// Check mission quality threshold (only if all tasks passed structurally)
|
|
658
|
+
if (allDone && this.qualityCtrl) {
|
|
659
|
+
const thresholdResult = this.qualityCtrl.checkMissionThreshold(mission, groupTasks, this.ctx.config.settings.defaultQualityThreshold);
|
|
660
|
+
if (!thresholdResult.passed) {
|
|
661
|
+
allDone = false; // Quality threshold not met — mark mission as failed
|
|
662
|
+
this.ctx.emitter.emit("log", {
|
|
663
|
+
level: "warn",
|
|
664
|
+
message: `Mission "${group}" quality threshold not met: ${thresholdResult.avgScore?.toFixed(2) ?? "N/A"} < ${thresholdResult.threshold}`,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
// Determine final status based on scheduled origin
|
|
669
|
+
const origin = this.scheduledOrigin.get(group);
|
|
670
|
+
let finalStatus;
|
|
671
|
+
if (origin === "recurring") {
|
|
672
|
+
// Recurring missions always return to "recurring" — ready for next cron tick
|
|
673
|
+
finalStatus = "recurring";
|
|
674
|
+
}
|
|
675
|
+
else if (origin === "scheduled" && !allDone) {
|
|
676
|
+
// One-shot scheduled missions return to "scheduled" on failure for retry
|
|
677
|
+
finalStatus = "scheduled";
|
|
678
|
+
}
|
|
679
|
+
else {
|
|
680
|
+
// Normal missions or successful one-shot scheduled missions
|
|
681
|
+
finalStatus = allDone ? "completed" : "failed";
|
|
682
|
+
}
|
|
683
|
+
await this.ctx.registry.updateMission?.(mission.id, { status: finalStatus });
|
|
684
|
+
const report = await this.buildMissionReport(mission.id, group, groupTasks, allDone);
|
|
685
|
+
this.ctx.emitter.emit("mission:completed", { missionId: mission.id, group, allPassed: allDone, report });
|
|
686
|
+
// Aggregate mission metrics
|
|
687
|
+
this.qualityCtrl?.aggregateMissionMetrics(mission.id, groupTasks);
|
|
688
|
+
// Clean up gate, checkpoint, delay, and scheduled-origin caches
|
|
689
|
+
this.gatesByGroup.delete(group);
|
|
690
|
+
this.scheduledOrigin.delete(group);
|
|
691
|
+
// Clean up persisted checkpoint entries for this group
|
|
692
|
+
this.cpState = await this.cpStore.removeGroup(this.cpState, group);
|
|
693
|
+
// Clean up persisted delay entries for this group
|
|
694
|
+
this.delayState = await this.delayStore.removeGroup(this.delayState, group);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
// ═══════════════════════════════════════════════════════
|
|
699
|
+
// ATOMIC MISSION DATA OPERATIONS
|
|
700
|
+
// Read-modify-write the `data` JSON blob without full replacement.
|
|
701
|
+
// ═══════════════════════════════════════════════════════
|
|
702
|
+
/**
|
|
703
|
+
* Parse a mission's `data` JSON and return the structured document.
|
|
704
|
+
* Throws if the mission is not found or if `data` is not valid JSON.
|
|
705
|
+
*/
|
|
706
|
+
async parseMissionData(missionId) {
|
|
707
|
+
const mission = await this.getMission(missionId);
|
|
708
|
+
if (!mission)
|
|
709
|
+
throw new Error("Mission not found");
|
|
710
|
+
const doc = parseMissionDocument(JSON.parse(mission.data));
|
|
711
|
+
return { mission, doc };
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Persist an updated document back onto the mission record.
|
|
715
|
+
* Re-validates through Zod to ensure integrity.
|
|
716
|
+
*/
|
|
717
|
+
async persistMissionData(missionId, doc) {
|
|
718
|
+
// Re-validate to catch any structural issue before persisting
|
|
719
|
+
parseMissionDocument(doc);
|
|
720
|
+
const mission = await this.updateMission(missionId, { data: JSON.stringify(doc) });
|
|
721
|
+
// Notify listeners so SSE clients (e.g. mission detail page) refetch updated data
|
|
722
|
+
this.ctx.emitter.emit("mission:saved", { missionId: mission.id, name: mission.name, status: mission.status });
|
|
723
|
+
return mission;
|
|
724
|
+
}
|
|
725
|
+
// ─── Task operations ────────────────────────────────
|
|
726
|
+
/** Add a task to a draft mission's data. */
|
|
727
|
+
async addMissionTask(missionId, task) {
|
|
728
|
+
const { doc } = await this.parseMissionData(missionId);
|
|
729
|
+
// Enforce unique title
|
|
730
|
+
if (doc.tasks.some(t => t.title === task.title)) {
|
|
731
|
+
throw new Error(`Task title "${task.title}" already exists in this mission`);
|
|
732
|
+
}
|
|
733
|
+
doc.tasks.push(task);
|
|
734
|
+
return this.persistMissionData(missionId, doc);
|
|
735
|
+
}
|
|
736
|
+
/** Update a specific task within the mission data (matched by title). */
|
|
737
|
+
async updateMissionTask(missionId, taskTitle, updates) {
|
|
738
|
+
const { doc } = await this.parseMissionData(missionId);
|
|
739
|
+
const idx = doc.tasks.findIndex(t => t.title === taskTitle);
|
|
740
|
+
if (idx === -1)
|
|
741
|
+
throw new Error(`Task "${taskTitle}" not found in mission`);
|
|
742
|
+
// If renaming, enforce unique title
|
|
743
|
+
if (updates.title && updates.title !== taskTitle && doc.tasks.some(t => t.title === updates.title)) {
|
|
744
|
+
throw new Error(`Task title "${updates.title}" already exists in this mission`);
|
|
745
|
+
}
|
|
746
|
+
doc.tasks[idx] = { ...doc.tasks[idx], ...updates };
|
|
747
|
+
return this.persistMissionData(missionId, doc);
|
|
748
|
+
}
|
|
749
|
+
/** Remove a task from the mission data (by title). Also cleans up dependsOn references. */
|
|
750
|
+
async removeMissionTask(missionId, taskTitle) {
|
|
751
|
+
const { doc } = await this.parseMissionData(missionId);
|
|
752
|
+
const idx = doc.tasks.findIndex(t => t.title === taskTitle);
|
|
753
|
+
if (idx === -1)
|
|
754
|
+
throw new Error(`Task "${taskTitle}" not found in mission`);
|
|
755
|
+
doc.tasks.splice(idx, 1);
|
|
756
|
+
// Clean up dependsOn references in remaining tasks
|
|
757
|
+
for (const t of doc.tasks) {
|
|
758
|
+
if (t.dependsOn) {
|
|
759
|
+
t.dependsOn = t.dependsOn.filter(d => d !== taskTitle);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
// Clean up quality gates and checkpoints that reference this task
|
|
763
|
+
if (doc.qualityGates) {
|
|
764
|
+
for (const gate of doc.qualityGates) {
|
|
765
|
+
gate.afterTasks = gate.afterTasks.filter(t => t !== taskTitle);
|
|
766
|
+
gate.blocksTasks = gate.blocksTasks.filter(t => t !== taskTitle);
|
|
767
|
+
}
|
|
768
|
+
// Remove gates that became empty
|
|
769
|
+
doc.qualityGates = doc.qualityGates.filter(g => g.afterTasks.length > 0 && g.blocksTasks.length > 0);
|
|
770
|
+
}
|
|
771
|
+
if (doc.checkpoints) {
|
|
772
|
+
for (const cp of doc.checkpoints) {
|
|
773
|
+
cp.afterTasks = cp.afterTasks.filter(t => t !== taskTitle);
|
|
774
|
+
cp.blocksTasks = cp.blocksTasks.filter(t => t !== taskTitle);
|
|
775
|
+
}
|
|
776
|
+
doc.checkpoints = doc.checkpoints.filter(cp => cp.afterTasks.length > 0 && cp.blocksTasks.length > 0);
|
|
777
|
+
}
|
|
778
|
+
if (doc.delays) {
|
|
779
|
+
for (const dl of doc.delays) {
|
|
780
|
+
dl.afterTasks = dl.afterTasks.filter(t => t !== taskTitle);
|
|
781
|
+
dl.blocksTasks = dl.blocksTasks.filter(t => t !== taskTitle);
|
|
782
|
+
}
|
|
783
|
+
doc.delays = doc.delays.filter(dl => dl.afterTasks.length > 0 && dl.blocksTasks.length > 0);
|
|
784
|
+
}
|
|
785
|
+
// Ensure at least 1 task remains (Zod will catch this, but give a nicer error)
|
|
786
|
+
if (doc.tasks.length === 0)
|
|
787
|
+
throw new Error("Cannot remove the last task from a mission");
|
|
788
|
+
return this.persistMissionData(missionId, doc);
|
|
789
|
+
}
|
|
790
|
+
/** Reorder tasks within the mission data. Accepts an array of task titles in the desired order. */
|
|
791
|
+
async reorderMissionTasks(missionId, titles) {
|
|
792
|
+
const { doc } = await this.parseMissionData(missionId);
|
|
793
|
+
const titleSet = new Set(titles);
|
|
794
|
+
if (titleSet.size !== titles.length)
|
|
795
|
+
throw new Error("Duplicate titles in reorder list");
|
|
796
|
+
const existing = new Set(doc.tasks.map(t => t.title));
|
|
797
|
+
for (const t of titles) {
|
|
798
|
+
if (!existing.has(t))
|
|
799
|
+
throw new Error(`Task "${t}" not found in mission`);
|
|
800
|
+
}
|
|
801
|
+
if (titles.length !== doc.tasks.length)
|
|
802
|
+
throw new Error("Reorder list must include all task titles");
|
|
803
|
+
const taskMap = new Map(doc.tasks.map(t => [t.title, t]));
|
|
804
|
+
doc.tasks = titles.map(t => taskMap.get(t));
|
|
805
|
+
return this.persistMissionData(missionId, doc);
|
|
806
|
+
}
|
|
807
|
+
// ─── Checkpoint operations ──────────────────────────
|
|
808
|
+
/** Add a checkpoint to a mission's data. */
|
|
809
|
+
async addMissionCheckpoint(missionId, checkpoint) {
|
|
810
|
+
const { doc } = await this.parseMissionData(missionId);
|
|
811
|
+
if (!doc.checkpoints)
|
|
812
|
+
doc.checkpoints = [];
|
|
813
|
+
if (doc.checkpoints.some(c => c.name === checkpoint.name)) {
|
|
814
|
+
throw new Error(`Checkpoint "${checkpoint.name}" already exists in this mission`);
|
|
815
|
+
}
|
|
816
|
+
doc.checkpoints.push(checkpoint);
|
|
817
|
+
return this.persistMissionData(missionId, doc);
|
|
818
|
+
}
|
|
819
|
+
/** Update a checkpoint in the mission data (matched by name). */
|
|
820
|
+
async updateMissionCheckpoint(missionId, checkpointName, updates) {
|
|
821
|
+
const { doc } = await this.parseMissionData(missionId);
|
|
822
|
+
if (!doc.checkpoints)
|
|
823
|
+
throw new Error(`Checkpoint "${checkpointName}" not found in mission`);
|
|
824
|
+
const idx = doc.checkpoints.findIndex(c => c.name === checkpointName);
|
|
825
|
+
if (idx === -1)
|
|
826
|
+
throw new Error(`Checkpoint "${checkpointName}" not found in mission`);
|
|
827
|
+
if (updates.name && updates.name !== checkpointName && doc.checkpoints.some(c => c.name === updates.name)) {
|
|
828
|
+
throw new Error(`Checkpoint "${updates.name}" already exists in this mission`);
|
|
829
|
+
}
|
|
830
|
+
doc.checkpoints[idx] = { ...doc.checkpoints[idx], ...updates };
|
|
831
|
+
return this.persistMissionData(missionId, doc);
|
|
832
|
+
}
|
|
833
|
+
/** Remove a checkpoint from the mission data (by name). */
|
|
834
|
+
async removeMissionCheckpoint(missionId, checkpointName) {
|
|
835
|
+
const { doc } = await this.parseMissionData(missionId);
|
|
836
|
+
if (!doc.checkpoints)
|
|
837
|
+
throw new Error(`Checkpoint "${checkpointName}" not found in mission`);
|
|
838
|
+
const idx = doc.checkpoints.findIndex(c => c.name === checkpointName);
|
|
839
|
+
if (idx === -1)
|
|
840
|
+
throw new Error(`Checkpoint "${checkpointName}" not found in mission`);
|
|
841
|
+
doc.checkpoints.splice(idx, 1);
|
|
842
|
+
if (doc.checkpoints.length === 0)
|
|
843
|
+
delete doc.checkpoints;
|
|
844
|
+
return this.persistMissionData(missionId, doc);
|
|
845
|
+
}
|
|
846
|
+
// ─── Delay operations ───────────────────────────────
|
|
847
|
+
/** Add a delay to a mission's data. */
|
|
848
|
+
async addMissionDelay(missionId, delay) {
|
|
849
|
+
// Validate duration format
|
|
850
|
+
parseISO8601Duration(delay.duration);
|
|
851
|
+
const { doc } = await this.parseMissionData(missionId);
|
|
852
|
+
if (!doc.delays)
|
|
853
|
+
doc.delays = [];
|
|
854
|
+
if (doc.delays.some(d => d.name === delay.name)) {
|
|
855
|
+
throw new Error(`Delay "${delay.name}" already exists in this mission`);
|
|
856
|
+
}
|
|
857
|
+
doc.delays.push(delay);
|
|
858
|
+
return this.persistMissionData(missionId, doc);
|
|
859
|
+
}
|
|
860
|
+
/** Update a delay in the mission data (matched by name). */
|
|
861
|
+
async updateMissionDelay(missionId, delayName, updates) {
|
|
862
|
+
if (updates.duration)
|
|
863
|
+
parseISO8601Duration(updates.duration);
|
|
864
|
+
const { doc } = await this.parseMissionData(missionId);
|
|
865
|
+
if (!doc.delays)
|
|
866
|
+
throw new Error(`Delay "${delayName}" not found in mission`);
|
|
867
|
+
const idx = doc.delays.findIndex(d => d.name === delayName);
|
|
868
|
+
if (idx === -1)
|
|
869
|
+
throw new Error(`Delay "${delayName}" not found in mission`);
|
|
870
|
+
if (updates.name && updates.name !== delayName && doc.delays.some(d => d.name === updates.name)) {
|
|
871
|
+
throw new Error(`Delay "${updates.name}" already exists in this mission`);
|
|
872
|
+
}
|
|
873
|
+
doc.delays[idx] = { ...doc.delays[idx], ...updates };
|
|
874
|
+
return this.persistMissionData(missionId, doc);
|
|
875
|
+
}
|
|
876
|
+
/** Remove a delay from the mission data (by name). */
|
|
877
|
+
async removeMissionDelay(missionId, delayName) {
|
|
878
|
+
const { doc } = await this.parseMissionData(missionId);
|
|
879
|
+
if (!doc.delays)
|
|
880
|
+
throw new Error(`Delay "${delayName}" not found in mission`);
|
|
881
|
+
const idx = doc.delays.findIndex(d => d.name === delayName);
|
|
882
|
+
if (idx === -1)
|
|
883
|
+
throw new Error(`Delay "${delayName}" not found in mission`);
|
|
884
|
+
doc.delays.splice(idx, 1);
|
|
885
|
+
if (doc.delays.length === 0)
|
|
886
|
+
delete doc.delays;
|
|
887
|
+
return this.persistMissionData(missionId, doc);
|
|
888
|
+
}
|
|
889
|
+
// ─── Quality gate operations ────────────────────────
|
|
890
|
+
/** Add a quality gate to a mission's data. */
|
|
891
|
+
async addMissionQualityGate(missionId, gate) {
|
|
892
|
+
const { doc } = await this.parseMissionData(missionId);
|
|
893
|
+
if (!doc.qualityGates)
|
|
894
|
+
doc.qualityGates = [];
|
|
895
|
+
if (doc.qualityGates.some(g => g.name === gate.name)) {
|
|
896
|
+
throw new Error(`Quality gate "${gate.name}" already exists in this mission`);
|
|
897
|
+
}
|
|
898
|
+
doc.qualityGates.push(gate);
|
|
899
|
+
return this.persistMissionData(missionId, doc);
|
|
900
|
+
}
|
|
901
|
+
/** Update a quality gate in the mission data (matched by name). */
|
|
902
|
+
async updateMissionQualityGate(missionId, gateName, updates) {
|
|
903
|
+
const { doc } = await this.parseMissionData(missionId);
|
|
904
|
+
if (!doc.qualityGates)
|
|
905
|
+
throw new Error(`Quality gate "${gateName}" not found in mission`);
|
|
906
|
+
const idx = doc.qualityGates.findIndex(g => g.name === gateName);
|
|
907
|
+
if (idx === -1)
|
|
908
|
+
throw new Error(`Quality gate "${gateName}" not found in mission`);
|
|
909
|
+
if (updates.name && updates.name !== gateName && doc.qualityGates.some(g => g.name === updates.name)) {
|
|
910
|
+
throw new Error(`Quality gate "${updates.name}" already exists in this mission`);
|
|
911
|
+
}
|
|
912
|
+
doc.qualityGates[idx] = { ...doc.qualityGates[idx], ...updates };
|
|
913
|
+
return this.persistMissionData(missionId, doc);
|
|
914
|
+
}
|
|
915
|
+
/** Remove a quality gate from the mission data (by name). */
|
|
916
|
+
async removeMissionQualityGate(missionId, gateName) {
|
|
917
|
+
const { doc } = await this.parseMissionData(missionId);
|
|
918
|
+
if (!doc.qualityGates)
|
|
919
|
+
throw new Error(`Quality gate "${gateName}" not found in mission`);
|
|
920
|
+
const idx = doc.qualityGates.findIndex(g => g.name === gateName);
|
|
921
|
+
if (idx === -1)
|
|
922
|
+
throw new Error(`Quality gate "${gateName}" not found in mission`);
|
|
923
|
+
doc.qualityGates.splice(idx, 1);
|
|
924
|
+
if (doc.qualityGates.length === 0)
|
|
925
|
+
delete doc.qualityGates;
|
|
926
|
+
return this.persistMissionData(missionId, doc);
|
|
927
|
+
}
|
|
928
|
+
// ─── Team (volatile agents) operations ──────────────
|
|
929
|
+
/** Add a team member to the mission's volatile team. */
|
|
930
|
+
async addMissionTeamMember(missionId, member) {
|
|
931
|
+
const { doc } = await this.parseMissionData(missionId);
|
|
932
|
+
if (!doc.team)
|
|
933
|
+
doc.team = [];
|
|
934
|
+
if (doc.team.some((m) => m.name === member.name)) {
|
|
935
|
+
throw new Error(`Team member "${member.name}" already exists in this mission`);
|
|
936
|
+
}
|
|
937
|
+
doc.team.push(member);
|
|
938
|
+
return this.persistMissionData(missionId, doc);
|
|
939
|
+
}
|
|
940
|
+
/** Update a team member in the mission data (matched by name). */
|
|
941
|
+
async updateMissionTeamMember(missionId, memberName, updates) {
|
|
942
|
+
const { doc } = await this.parseMissionData(missionId);
|
|
943
|
+
if (!doc.team)
|
|
944
|
+
throw new Error(`Team member "${memberName}" not found in mission`);
|
|
945
|
+
const team = doc.team;
|
|
946
|
+
const idx = team.findIndex((m) => m.name === memberName);
|
|
947
|
+
if (idx === -1)
|
|
948
|
+
throw new Error(`Team member "${memberName}" not found in mission`);
|
|
949
|
+
if (updates.name && updates.name !== memberName && team.some((m) => m.name === updates.name)) {
|
|
950
|
+
throw new Error(`Team member "${updates.name}" already exists in this mission`);
|
|
951
|
+
}
|
|
952
|
+
team[idx] = { ...team[idx], ...updates };
|
|
953
|
+
return this.persistMissionData(missionId, doc);
|
|
954
|
+
}
|
|
955
|
+
/** Remove a team member from the mission data (by name). */
|
|
956
|
+
async removeMissionTeamMember(missionId, memberName) {
|
|
957
|
+
const { doc } = await this.parseMissionData(missionId);
|
|
958
|
+
if (!doc.team)
|
|
959
|
+
throw new Error(`Team member "${memberName}" not found in mission`);
|
|
960
|
+
const team = doc.team;
|
|
961
|
+
const idx = team.findIndex((m) => m.name === memberName);
|
|
962
|
+
if (idx === -1)
|
|
963
|
+
throw new Error(`Team member "${memberName}" not found in mission`);
|
|
964
|
+
team.splice(idx, 1);
|
|
965
|
+
if (team.length === 0)
|
|
966
|
+
delete doc.team;
|
|
967
|
+
return this.persistMissionData(missionId, doc);
|
|
968
|
+
}
|
|
969
|
+
// ─── Notifications operations ───────────────────────
|
|
970
|
+
/** Update the mission-level notification rules. */
|
|
971
|
+
async updateMissionNotifications(missionId, notifications) {
|
|
972
|
+
const { doc } = await this.parseMissionData(missionId);
|
|
973
|
+
if (notifications === null) {
|
|
974
|
+
delete doc.notifications;
|
|
975
|
+
}
|
|
976
|
+
else {
|
|
977
|
+
doc.notifications = notifications;
|
|
978
|
+
}
|
|
979
|
+
return this.persistMissionData(missionId, doc);
|
|
980
|
+
}
|
|
981
|
+
async buildMissionReport(missionId, group, groupTasks, allPassed) {
|
|
982
|
+
const state = await this.ctx.registry.getState();
|
|
983
|
+
const processes = state?.processes ?? [];
|
|
984
|
+
const allFilesCreated = new Set();
|
|
985
|
+
const allFilesEdited = new Set();
|
|
986
|
+
let totalDuration = 0;
|
|
987
|
+
const scores = [];
|
|
988
|
+
const allOutcomes = [];
|
|
989
|
+
const taskReports = groupTasks.map(t => {
|
|
990
|
+
const duration = t.result?.duration ?? 0;
|
|
991
|
+
totalDuration += duration;
|
|
992
|
+
const score = t.result?.assessment?.globalScore;
|
|
993
|
+
if (score !== undefined)
|
|
994
|
+
scores.push(score);
|
|
995
|
+
// Get file activity from processes (may already be gone for completed tasks)
|
|
996
|
+
const proc = processes.find(p => p.taskId === t.id);
|
|
997
|
+
const filesCreated = proc?.activity?.filesCreated ?? [];
|
|
998
|
+
const filesEdited = proc?.activity?.filesEdited ?? [];
|
|
999
|
+
for (const f of filesCreated)
|
|
1000
|
+
allFilesCreated.add(f);
|
|
1001
|
+
for (const f of filesEdited)
|
|
1002
|
+
allFilesEdited.add(f);
|
|
1003
|
+
// Aggregate outcomes across all tasks
|
|
1004
|
+
if (t.outcomes) {
|
|
1005
|
+
for (const o of t.outcomes)
|
|
1006
|
+
allOutcomes.push(o);
|
|
1007
|
+
}
|
|
1008
|
+
return {
|
|
1009
|
+
title: t.title,
|
|
1010
|
+
status: t.status,
|
|
1011
|
+
duration,
|
|
1012
|
+
score,
|
|
1013
|
+
filesCreated,
|
|
1014
|
+
filesEdited,
|
|
1015
|
+
outcomes: t.outcomes,
|
|
1016
|
+
};
|
|
1017
|
+
});
|
|
1018
|
+
const avgScore = scores.length > 0
|
|
1019
|
+
? scores.reduce((a, b) => a + b, 0) / scores.length
|
|
1020
|
+
: undefined;
|
|
1021
|
+
return {
|
|
1022
|
+
missionId,
|
|
1023
|
+
group,
|
|
1024
|
+
allPassed,
|
|
1025
|
+
totalDuration,
|
|
1026
|
+
tasks: taskReports,
|
|
1027
|
+
filesCreated: [...allFilesCreated],
|
|
1028
|
+
filesEdited: [...allFilesEdited],
|
|
1029
|
+
outcomes: allOutcomes.length > 0 ? allOutcomes : undefined,
|
|
1030
|
+
avgScore,
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
// ── ISO 8601 Duration Parser ──────────────────────────────────────────
|
|
1035
|
+
// Supports: P[nY][nM][nW][nD][T[nH][nM][nS]]
|
|
1036
|
+
// Examples: PT2H (2 hours), PT30M (30 min), P1D (1 day), P1DT6H (1 day 6 hours)
|
|
1037
|
+
const ISO_DURATION_RE = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/;
|
|
1038
|
+
function parseISO8601Duration(duration) {
|
|
1039
|
+
const m = ISO_DURATION_RE.exec(duration);
|
|
1040
|
+
if (!m)
|
|
1041
|
+
throw new Error(`Invalid ISO 8601 duration: "${duration}"`);
|
|
1042
|
+
const years = parseInt(m[1] || "0", 10);
|
|
1043
|
+
const months = parseInt(m[2] || "0", 10);
|
|
1044
|
+
const weeks = parseInt(m[3] || "0", 10);
|
|
1045
|
+
const days = parseInt(m[4] || "0", 10);
|
|
1046
|
+
const hours = parseInt(m[5] || "0", 10);
|
|
1047
|
+
const minutes = parseInt(m[6] || "0", 10);
|
|
1048
|
+
const seconds = parseFloat(m[7] || "0");
|
|
1049
|
+
// Approximate: 1 year ≈ 365.25 days, 1 month ≈ 30.44 days
|
|
1050
|
+
const totalDays = years * 365.25 + months * 30.44 + weeks * 7 + days;
|
|
1051
|
+
return ((totalDays * 24 + hours) * 60 + minutes) * 60000 + seconds * 1000;
|
|
1052
|
+
}
|
|
1053
|
+
//# sourceMappingURL=mission-executor.js.map
|