@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.
@@ -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