@tangle-network/agent-app 0.7.0 → 0.7.2

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,1074 @@
1
+ // src/missions/service.ts
2
+ var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
3
+ "succeeded",
4
+ "failed",
5
+ "aborted",
6
+ "cancelled"
7
+ ]);
8
+ function isMissionTerminal(status) {
9
+ return TERMINAL_STATUSES.has(status);
10
+ }
11
+ function isMissionStopRequested(mission) {
12
+ return (mission.metadata ?? {}).stopRequested === true;
13
+ }
14
+ var MISSION_TRANSITIONS = {
15
+ scheduled: /* @__PURE__ */ new Set(["running", "cancelled", "aborted"]),
16
+ running: /* @__PURE__ */ new Set([
17
+ "paused",
18
+ "waiting_approval",
19
+ "blocked",
20
+ "succeeded",
21
+ "failed",
22
+ "aborted"
23
+ ]),
24
+ paused: /* @__PURE__ */ new Set(["running", "aborted", "cancelled"]),
25
+ waiting_approval: /* @__PURE__ */ new Set(["running", "aborted", "cancelled"]),
26
+ blocked: /* @__PURE__ */ new Set(["running", "aborted", "cancelled"]),
27
+ succeeded: /* @__PURE__ */ new Set(),
28
+ failed: /* @__PURE__ */ new Set(),
29
+ aborted: /* @__PURE__ */ new Set(),
30
+ cancelled: /* @__PURE__ */ new Set()
31
+ };
32
+ var STEP_TRANSITIONS = {
33
+ pending: /* @__PURE__ */ new Set(["running", "waiting_approval", "failed"]),
34
+ running: /* @__PURE__ */ new Set(["done", "failed", "waiting_approval"]),
35
+ waiting_approval: /* @__PURE__ */ new Set(["running", "done", "failed"]),
36
+ done: /* @__PURE__ */ new Set([]),
37
+ failed: /* @__PURE__ */ new Set(["pending", "running"])
38
+ };
39
+ var ZERO_LEDGER = {
40
+ tokensIn: 0,
41
+ tokensOut: 0,
42
+ costUsd: 0,
43
+ wallMs: 0,
44
+ llmCalls: 0
45
+ };
46
+ function rejected(error) {
47
+ return { succeeded: false, error, conflict: false };
48
+ }
49
+ function lostRace(id) {
50
+ return { succeeded: false, error: `Mission ${id} changed concurrently`, conflict: true };
51
+ }
52
+ function createMissionService(options) {
53
+ const { store } = options;
54
+ const now = options.now ?? (() => Date.now());
55
+ const generateId = options.generateId ?? (() => crypto.randomUUID());
56
+ async function appendEvent(mission, level, step, message, metadata = {}) {
57
+ await store.appendEvent({
58
+ missionId: mission.id,
59
+ workspaceId: mission.workspaceId,
60
+ level,
61
+ step,
62
+ message,
63
+ metadata,
64
+ at: now()
65
+ });
66
+ }
67
+ async function transition(id, to, patch = {}, eventMeta = {}) {
68
+ const mission = await store.load(id);
69
+ if (!mission) return rejected(`Mission ${id} not found`);
70
+ const from = mission.status;
71
+ if (TERMINAL_STATUSES.has(from)) {
72
+ return rejected(`Mission ${id} is terminal (${from}); cannot transition to ${to}`);
73
+ }
74
+ if (from === to) return { succeeded: true, value: mission };
75
+ if (!MISSION_TRANSITIONS[from].has(to)) {
76
+ return rejected(`Illegal mission transition ${from} -> ${to} for mission ${id}`);
77
+ }
78
+ const updated = await store.update(id, { status: from }, { status: to, ...patch });
79
+ if (!updated) return lostRace(id);
80
+ await appendEvent(updated, to === "failed" ? "error" : "info", `mission.${to}`, `Mission ${from} -> ${to}`, {
81
+ from,
82
+ to,
83
+ ...eventMeta
84
+ });
85
+ return { succeeded: true, value: updated };
86
+ }
87
+ const createMission = async (input) => {
88
+ const seen = /* @__PURE__ */ new Set();
89
+ for (const step of input.plan) {
90
+ if (seen.has(step.id)) {
91
+ throw new Error(`Duplicate plan step id "${step.id}" \u2014 mission plan step ids must be unique`);
92
+ }
93
+ seen.add(step.id);
94
+ }
95
+ const scheduledAt = input.scheduledAt ?? null;
96
+ const status = scheduledAt !== null ? "scheduled" : "running";
97
+ const plan = input.plan.map((step) => ({
98
+ id: step.id,
99
+ intent: step.intent,
100
+ kind: step.kind,
101
+ status: step.status,
102
+ attempts: step.attempts,
103
+ ...step.sublabel === void 0 ? {} : { sublabel: step.sublabel },
104
+ ...step.resultRef === void 0 ? {} : { resultRef: step.resultRef }
105
+ }));
106
+ const record = await store.insert({
107
+ id: input.id ?? generateId(),
108
+ workspaceId: input.workspaceId,
109
+ status,
110
+ trigger: input.trigger,
111
+ summary: input.title,
112
+ plan,
113
+ cursor: 0,
114
+ cost: { ...ZERO_LEDGER },
115
+ budgetUsd: input.budgetUsd ?? null,
116
+ spentUsd: 0,
117
+ pauseReason: null,
118
+ engineRef: null,
119
+ scheduledAt,
120
+ startedAt: now(),
121
+ completedAt: null,
122
+ metadata: input.metadata ?? null
123
+ });
124
+ await appendEvent(record, "info", "mission.created", `Mission "${input.title}" ${status}`, {
125
+ status,
126
+ stepCount: plan.length,
127
+ budgetUsd: input.budgetUsd ?? null,
128
+ scheduledAt
129
+ });
130
+ return record;
131
+ };
132
+ const getMission = (id) => store.load(id);
133
+ const setEngineRef = async (id, engineRef) => {
134
+ const mission = await store.load(id);
135
+ if (!mission) return rejected(`Mission ${id} not found`);
136
+ if (TERMINAL_STATUSES.has(mission.status) || isMissionStopRequested(mission)) {
137
+ return rejected(`Mission ${id} is not writable in status ${mission.status}`);
138
+ }
139
+ if (mission.engineRef === engineRef) return { succeeded: true, value: mission };
140
+ if (mission.engineRef !== null) {
141
+ return rejected(`Mission ${id} is already bound to engine ${mission.engineRef}`);
142
+ }
143
+ const updated = await store.update(id, { engineRefIsNull: true }, { engineRef });
144
+ if (!updated) return lostRace(id);
145
+ await appendEvent(updated, "info", "mission.engine", `Engine bound: ${engineRef}`, { engineRef });
146
+ return { succeeded: true, value: updated };
147
+ };
148
+ const mergeMetadata = async (id, patch) => {
149
+ const mission = await store.load(id);
150
+ if (!mission) return rejected(`Mission ${id} not found`);
151
+ if (TERMINAL_STATUSES.has(mission.status) || isMissionStopRequested(mission)) {
152
+ return rejected(`Mission ${id} is not writable in status ${mission.status}`);
153
+ }
154
+ const updated = await store.update(
155
+ id,
156
+ { metadata: mission.metadata },
157
+ { metadata: { ...mission.metadata ?? {}, ...patch } }
158
+ );
159
+ if (!updated) return lostRace(id);
160
+ return { succeeded: true, value: updated };
161
+ };
162
+ const setStepStatus = async (id, stepId, status, patch = {}) => {
163
+ const mission = await store.load(id);
164
+ if (!mission) return rejected(`Mission ${id} not found`);
165
+ const plan = mission.plan;
166
+ const index = plan.findIndex((step) => step.id === stepId);
167
+ const current = index < 0 ? void 0 : plan[index];
168
+ if (!current) return rejected(`Step ${stepId} not found in mission ${id}`);
169
+ const sameStatus = current.status === status;
170
+ if (!sameStatus && !STEP_TRANSITIONS[current.status].has(status)) {
171
+ return rejected(`Illegal step transition ${current.status} -> ${status} for step ${stepId}`);
172
+ }
173
+ const sublabelChanges = patch.sublabel !== void 0 && patch.sublabel !== current.sublabel;
174
+ const resultRefChanges = patch.resultRef !== void 0 && patch.resultRef !== current.resultRef;
175
+ if (sameStatus && !sublabelChanges && !resultRefChanges) {
176
+ return { succeeded: true, value: mission };
177
+ }
178
+ const nextStep = {
179
+ ...current,
180
+ status,
181
+ attempts: status === "running" && !sameStatus ? current.attempts + 1 : current.attempts,
182
+ ...patch.sublabel === void 0 ? {} : { sublabel: patch.sublabel },
183
+ ...patch.resultRef === void 0 ? {} : { resultRef: patch.resultRef }
184
+ };
185
+ const nextPlan = plan.slice();
186
+ nextPlan[index] = nextStep;
187
+ const updated = await store.update(
188
+ id,
189
+ { status: mission.status, plan: mission.plan, metadata: mission.metadata },
190
+ { plan: nextPlan }
191
+ );
192
+ if (!updated) return lostRace(id);
193
+ await appendEvent(
194
+ updated,
195
+ status === "failed" ? "error" : "info",
196
+ `mission.step.${status}`,
197
+ patch.error ?? `Step ${stepId} (${current.intent}) -> ${status}`,
198
+ {
199
+ stepId,
200
+ from: current.status,
201
+ to: status,
202
+ attempts: nextStep.attempts,
203
+ ...patch.resultRef ? { resultRef: patch.resultRef } : {}
204
+ }
205
+ );
206
+ return { succeeded: true, value: updated };
207
+ };
208
+ const advanceCursor = async (id) => {
209
+ const mission = await store.load(id);
210
+ if (!mission) return rejected(`Mission ${id} not found`);
211
+ const next = mission.cursor + 1;
212
+ if (next > mission.plan.length) {
213
+ return rejected(`Cursor ${mission.cursor} is already at the end of mission ${id}`);
214
+ }
215
+ const updated = await store.update(
216
+ id,
217
+ { status: mission.status, cursor: mission.cursor },
218
+ { cursor: next }
219
+ );
220
+ if (!updated) return lostRace(id);
221
+ await appendEvent(updated, "info", "mission.cursor", `Cursor ${mission.cursor} -> ${updated.cursor}`, {
222
+ from: mission.cursor,
223
+ to: updated.cursor
224
+ });
225
+ return { succeeded: true, value: updated };
226
+ };
227
+ const addCost = async (id, deltaUsd, ledgerDelta) => {
228
+ const mission = await store.load(id);
229
+ if (!mission) return rejected(`Mission ${id} not found`);
230
+ const base = mission.cost ?? { ...ZERO_LEDGER };
231
+ const nextCost = {
232
+ tokensIn: base.tokensIn + (ledgerDelta?.tokensIn ?? 0),
233
+ tokensOut: base.tokensOut + (ledgerDelta?.tokensOut ?? 0),
234
+ costUsd: base.costUsd + (ledgerDelta?.costUsd ?? deltaUsd),
235
+ wallMs: base.wallMs + (ledgerDelta?.wallMs ?? 0),
236
+ llmCalls: base.llmCalls + (ledgerDelta?.llmCalls ?? 0)
237
+ };
238
+ const updated = await store.update(
239
+ id,
240
+ { cost: mission.cost },
241
+ { cost: nextCost, spentUsd: mission.spentUsd + deltaUsd }
242
+ );
243
+ if (!updated) return lostRace(id);
244
+ await appendEvent(updated, "info", "mission.cost", `Spent +$${deltaUsd.toFixed(4)}`, {
245
+ deltaUsd,
246
+ spentUsd: updated.spentUsd,
247
+ budgetUsd: updated.budgetUsd
248
+ });
249
+ return { succeeded: true, value: updated };
250
+ };
251
+ const markWaitingApproval = async (id, stepId) => {
252
+ const mission = await store.load(id);
253
+ if (!mission) return rejected(`Mission ${id} not found`);
254
+ if (!MISSION_TRANSITIONS[mission.status].has("waiting_approval")) {
255
+ return rejected(`Illegal mission transition ${mission.status} -> waiting_approval for mission ${id}`);
256
+ }
257
+ const stepResult = await setStepStatus(id, stepId, "waiting_approval");
258
+ if (!stepResult.succeeded) return stepResult;
259
+ return transition(id, "waiting_approval", {}, { stepId });
260
+ };
261
+ return {
262
+ createMission,
263
+ getMission,
264
+ setEngineRef,
265
+ mergeMetadata,
266
+ setStepStatus,
267
+ advanceCursor,
268
+ addCost,
269
+ markWaitingApproval,
270
+ pause: (id, reason) => transition(id, "paused", { pauseReason: reason }),
271
+ resume: (id) => transition(id, "running", { pauseReason: null }),
272
+ abort: (id) => transition(id, "aborted", { completedAt: now() }),
273
+ complete: (id, input) => transition(id, input.ok ? "succeeded" : "failed", {
274
+ completedAt: now(),
275
+ ...input.summary === void 0 ? {} : { summary: input.summary }
276
+ })
277
+ };
278
+ }
279
+ function createInMemoryMissionStore() {
280
+ const rows = /* @__PURE__ */ new Map();
281
+ const events = [];
282
+ return {
283
+ async load(id) {
284
+ const record = rows.get(id);
285
+ return record ? structuredClone(record) : null;
286
+ },
287
+ async insert(record) {
288
+ if (rows.has(record.id)) throw new Error(`Mission ${record.id} already exists`);
289
+ rows.set(record.id, structuredClone(record));
290
+ return structuredClone(record);
291
+ },
292
+ async update(id, guard, patch) {
293
+ const current = rows.get(id);
294
+ if (!current) return null;
295
+ if (guard.status !== void 0 && current.status !== guard.status) return null;
296
+ if (guard.cursor !== void 0 && current.cursor !== guard.cursor) return null;
297
+ if (guard.plan !== void 0 && JSON.stringify(current.plan) !== JSON.stringify(guard.plan)) return null;
298
+ if (guard.cost !== void 0 && JSON.stringify(current.cost) !== JSON.stringify(guard.cost)) return null;
299
+ if (guard.metadata !== void 0 && JSON.stringify(current.metadata) !== JSON.stringify(guard.metadata)) {
300
+ return null;
301
+ }
302
+ if (guard.engineRefIsNull && current.engineRef !== null) return null;
303
+ const next = { ...current };
304
+ if (patch.status !== void 0) next.status = patch.status;
305
+ if (patch.pauseReason !== void 0) next.pauseReason = patch.pauseReason;
306
+ if (patch.summary !== void 0) next.summary = patch.summary;
307
+ if (patch.completedAt !== void 0) next.completedAt = patch.completedAt;
308
+ if (patch.plan !== void 0) next.plan = patch.plan;
309
+ if (patch.cursor !== void 0) next.cursor = patch.cursor;
310
+ if (patch.cost !== void 0) next.cost = patch.cost;
311
+ if (patch.spentUsd !== void 0) next.spentUsd = patch.spentUsd;
312
+ if (patch.metadata !== void 0) next.metadata = patch.metadata;
313
+ if (patch.engineRef !== void 0) next.engineRef = patch.engineRef;
314
+ rows.set(id, structuredClone(next));
315
+ return structuredClone(next);
316
+ },
317
+ async appendEvent(event) {
318
+ events.push(structuredClone(event));
319
+ },
320
+ events() {
321
+ return events.map((event) => structuredClone(event));
322
+ },
323
+ put(record) {
324
+ rows.set(record.id, structuredClone(record));
325
+ }
326
+ };
327
+ }
328
+
329
+ // src/missions/events.ts
330
+ var noopEventSink = { emit() {
331
+ } };
332
+ var MISSION_CONTROL_CHANNEL_ID = "missions";
333
+ var MISSION_EVENT_TYPES = /* @__PURE__ */ new Set([
334
+ "mission.created",
335
+ "mission.started",
336
+ "step.started",
337
+ "step.updated",
338
+ "step.completed",
339
+ "cost.updated",
340
+ "mission.paused",
341
+ "mission.waiting_approval",
342
+ "mission.resumed",
343
+ "mission.plan.updated",
344
+ "mission.completed"
345
+ ]);
346
+ function parseSessionStreamEnvelope(raw) {
347
+ if (!raw || typeof raw !== "object") return null;
348
+ const envelope = raw;
349
+ if (typeof envelope.type !== "string") return null;
350
+ const data = envelope.data && typeof envelope.data === "object" ? envelope.data : {};
351
+ return asMissionStreamEvent({ ...data, type: envelope.type });
352
+ }
353
+ function asMissionStreamEvent(value) {
354
+ if (!value || typeof value !== "object") return null;
355
+ const record = value;
356
+ const type = record.type;
357
+ if (typeof type !== "string" || !MISSION_EVENT_TYPES.has(type)) return null;
358
+ if (typeof record.missionId !== "string" || !record.missionId) return null;
359
+ return value;
360
+ }
361
+ var STEP_RANK = {
362
+ pending: 0,
363
+ running: 1,
364
+ waiting_approval: 2,
365
+ // done and failed are both TERMINAL for a step; rank them equal-and-highest
366
+ // so neither can be overwritten by the other or regressed to running.
367
+ done: 3,
368
+ failed: 3
369
+ };
370
+ var MISSION_RANK = {
371
+ scheduled: 0,
372
+ running: 1,
373
+ paused: 2,
374
+ waiting_approval: 2,
375
+ succeeded: 3,
376
+ aborted: 3,
377
+ cancelled: 3,
378
+ failed: 3
379
+ };
380
+ function maxStepStatus(current, next) {
381
+ if (STEP_RANK[next] <= STEP_RANK[current]) return current;
382
+ return next;
383
+ }
384
+ function maxMissionStatus(current, next) {
385
+ if (MISSION_RANK[next] <= MISSION_RANK[current]) return current;
386
+ return next;
387
+ }
388
+ function emptyMission(missionId) {
389
+ return {
390
+ missionId,
391
+ status: "running",
392
+ steps: [],
393
+ spentUsd: 0,
394
+ lastEventAt: 0,
395
+ lastControlAt: 0
396
+ };
397
+ }
398
+ function stepStateFrom(step) {
399
+ return { id: step.id, intent: step.intent, kind: step.kind, status: step.status };
400
+ }
401
+ function applyMissionEvent(prev, event) {
402
+ const at = typeof event.at === "number" ? event.at : 0;
403
+ const base = prev ?? emptyMission(event.missionId);
404
+ const lastEventAt = Math.max(base.lastEventAt, at);
405
+ const lastControlAt = base.lastControlAt ?? 0;
406
+ switch (event.type) {
407
+ case "mission.created": {
408
+ const merged = event.steps.map((incoming) => {
409
+ const existing = base.steps.find((s) => s.id === incoming.id);
410
+ if (!existing) return stepStateFrom(incoming);
411
+ return {
412
+ ...existing,
413
+ intent: incoming.intent,
414
+ kind: incoming.kind,
415
+ status: maxStepStatus(existing.status, incoming.status)
416
+ };
417
+ });
418
+ for (const known of base.steps) {
419
+ if (!merged.some((s) => s.id === known.id)) merged.push(known);
420
+ }
421
+ return {
422
+ ...base,
423
+ title: event.title || base.title,
424
+ status: prev ? maxMissionStatus(base.status, event.status ?? "running") : event.status ?? base.status,
425
+ capUsd: event.budgetUsd ?? base.capUsd,
426
+ steps: merged,
427
+ lastEventAt
428
+ };
429
+ }
430
+ case "mission.started":
431
+ return { ...base, status: maxMissionStatus(base.status, "running"), lastEventAt };
432
+ case "step.started":
433
+ return {
434
+ ...base,
435
+ steps: upsertStep(base.steps, event.stepId, (step) => ({
436
+ ...step,
437
+ status: maxStepStatus(step.status, "running")
438
+ })),
439
+ lastEventAt
440
+ };
441
+ case "step.updated":
442
+ return {
443
+ ...base,
444
+ steps: upsertStep(base.steps, event.stepId, (step) => ({
445
+ ...step,
446
+ // A sublabel is a live counter ("7/15") — always take the latest; it
447
+ // does not move status.
448
+ sublabel: event.sublabel
449
+ })),
450
+ lastEventAt
451
+ };
452
+ case "step.completed":
453
+ return {
454
+ ...base,
455
+ steps: upsertStep(base.steps, event.stepId, (step) => ({
456
+ ...step,
457
+ status: maxStepStatus(step.status, event.ok ? "done" : "failed"),
458
+ ...event.reason !== void 0 ? { reason: event.reason } : {},
459
+ ...event.durationMs !== void 0 ? { durationMs: event.durationMs } : {}
460
+ })),
461
+ lastEventAt
462
+ };
463
+ case "cost.updated":
464
+ return {
465
+ ...base,
466
+ // spentUsd is cumulative and monotonically non-decreasing at the
467
+ // source; clamp so an out-of-order older value never lowers the
468
+ // displayed spend.
469
+ spentUsd: Math.max(base.spentUsd, event.spentUsd),
470
+ capUsd: event.capUsd ?? base.capUsd,
471
+ lastEventAt
472
+ };
473
+ case "mission.paused":
474
+ if (at <= lastControlAt) return { ...base, lastEventAt };
475
+ return {
476
+ ...base,
477
+ status: maxMissionStatus(base.status, "paused"),
478
+ ...event.reason !== void 0 ? { pauseReason: event.reason } : {},
479
+ lastEventAt,
480
+ lastControlAt: Math.max(lastControlAt, at)
481
+ };
482
+ case "mission.waiting_approval":
483
+ if (at <= lastControlAt) return { ...base, lastEventAt };
484
+ return {
485
+ ...base,
486
+ status: maxMissionStatus(base.status, "waiting_approval"),
487
+ ...event.reason !== void 0 ? { pauseReason: event.reason } : {},
488
+ lastEventAt,
489
+ lastControlAt: Math.max(lastControlAt, at)
490
+ };
491
+ case "mission.resumed":
492
+ if (at <= lastControlAt) return { ...base, lastEventAt };
493
+ return {
494
+ ...base,
495
+ status: isTerminalStreamStatus(base.status) ? base.status : "running",
496
+ pauseReason: void 0,
497
+ lastEventAt,
498
+ lastControlAt: Math.max(lastControlAt, at)
499
+ };
500
+ case "mission.plan.updated":
501
+ return {
502
+ ...base,
503
+ title: event.title || base.title,
504
+ capUsd: event.budgetUsd ?? base.capUsd,
505
+ steps: event.steps.map((incoming) => {
506
+ const existing = base.steps.find((s) => s.id === incoming.id);
507
+ if (!existing) return stepStateFrom(incoming);
508
+ return {
509
+ ...stepStateFrom(incoming),
510
+ status: maxStepStatus(existing.status, incoming.status),
511
+ ...existing.sublabel !== void 0 ? { sublabel: existing.sublabel } : {},
512
+ ...existing.reason !== void 0 ? { reason: existing.reason } : {},
513
+ ...existing.durationMs !== void 0 ? { durationMs: existing.durationMs } : {}
514
+ };
515
+ }),
516
+ lastEventAt
517
+ };
518
+ case "mission.completed":
519
+ return {
520
+ ...base,
521
+ status: maxMissionStatus(base.status, event.status ?? (event.ok ? "succeeded" : "failed")),
522
+ ...event.summary !== void 0 ? { summary: event.summary } : {},
523
+ lastEventAt
524
+ };
525
+ }
526
+ }
527
+ function isTerminalStreamStatus(status) {
528
+ return status === "succeeded" || status === "failed" || status === "aborted" || status === "cancelled";
529
+ }
530
+ function upsertStep(steps, stepId, patch) {
531
+ const index = steps.findIndex((s) => s.id === stepId);
532
+ const existing = index < 0 ? void 0 : steps[index];
533
+ if (!existing) {
534
+ const placeholder = {
535
+ id: stepId,
536
+ intent: "",
537
+ kind: "",
538
+ status: "pending"
539
+ };
540
+ return [...steps, patch(placeholder)];
541
+ }
542
+ const next = steps.slice();
543
+ next[index] = patch(existing);
544
+ return next;
545
+ }
546
+ function mergeMissionState(live, seed) {
547
+ if (!live) return seed;
548
+ const steps = [];
549
+ for (const seededStep of seed.steps) {
550
+ const current = live.steps.find((s) => s.id === seededStep.id);
551
+ if (!current) {
552
+ steps.push(seededStep);
553
+ continue;
554
+ }
555
+ steps.push({
556
+ ...seededStep,
557
+ intent: current.intent || seededStep.intent,
558
+ kind: current.kind || seededStep.kind,
559
+ status: maxStepStatus(current.status, seededStep.status),
560
+ ...current.sublabel !== void 0 ? { sublabel: current.sublabel } : seededStep.sublabel !== void 0 ? { sublabel: seededStep.sublabel } : {},
561
+ ...current.reason !== void 0 ? { reason: current.reason } : seededStep.reason !== void 0 ? { reason: seededStep.reason } : {},
562
+ ...current.durationMs !== void 0 ? { durationMs: current.durationMs } : seededStep.durationMs !== void 0 ? { durationMs: seededStep.durationMs } : {}
563
+ });
564
+ }
565
+ for (const current of live.steps) {
566
+ if (seed.steps.some((step) => step.id === current.id)) continue;
567
+ if (hasStepProgressEvidence(current)) steps.push(current);
568
+ }
569
+ const status = mergeSeedMissionStatus(live.status, seed.status, live.lastControlAt ?? 0, seed.lastControlAt ?? 0);
570
+ return {
571
+ ...live,
572
+ title: live.title ?? seed.title,
573
+ status,
574
+ steps,
575
+ spentUsd: Math.max(live.spentUsd, seed.spentUsd),
576
+ capUsd: live.capUsd ?? seed.capUsd,
577
+ pauseReason: status === "running" || status === "scheduled" ? void 0 : seed.pauseReason ?? live.pauseReason,
578
+ summary: live.summary ?? seed.summary,
579
+ lastEventAt: Math.max(live.lastEventAt, seed.lastEventAt),
580
+ lastControlAt: Math.max(live.lastControlAt ?? 0, seed.lastControlAt ?? 0)
581
+ };
582
+ }
583
+ function hasStepProgressEvidence(step) {
584
+ return step.status !== "pending" || step.sublabel !== void 0 || step.reason !== void 0 || step.durationMs !== void 0;
585
+ }
586
+ function mergeSeedMissionStatus(liveStatus, seedStatus, liveControlAt, seedControlAt) {
587
+ if (isTerminalStreamStatus(liveStatus)) return liveStatus;
588
+ if (isTerminalStreamStatus(seedStatus)) return seedStatus;
589
+ if ((seedStatus === "paused" || seedStatus === "waiting_approval") && liveControlAt > seedControlAt) {
590
+ return liveStatus;
591
+ }
592
+ if (seedStatus === "running") return "running";
593
+ if (seedStatus === "scheduled" && liveStatus !== "scheduled") return liveStatus;
594
+ return maxMissionStatus(liveStatus, seedStatus);
595
+ }
596
+ function reduceMissionEvents(events, seed) {
597
+ const next = new Map(seed ?? []);
598
+ for (const event of events) {
599
+ next.set(event.missionId, applyMissionEvent(next.get(event.missionId), event));
600
+ }
601
+ return next;
602
+ }
603
+
604
+ // src/missions/engine.ts
605
+ var MissionConcurrencyError = class extends Error {
606
+ constructor(message) {
607
+ super(message);
608
+ this.name = "MissionConcurrencyError";
609
+ }
610
+ };
611
+ var RetryableStepError = class extends Error {
612
+ constructor(message) {
613
+ super(message);
614
+ this.name = "RetryableStepError";
615
+ }
616
+ };
617
+ var DEFAULT_EXTERNAL_ACTION_CAP = 5;
618
+ var DEFAULT_NON_FATAL_STEP_KINDS = ["optional", "best-effort"];
619
+ function stepGateProposalId(missionId, stepId) {
620
+ return `mission-step-gate:${missionId}:${stepId}`;
621
+ }
622
+ function budgetGateProposalId(missionId, stepId) {
623
+ return `mission-budget-gate:${missionId}:${stepId}`;
624
+ }
625
+ function volumeGateProposalId(missionId, stepId) {
626
+ return `mission-volume-gate:${missionId}:${stepId}`;
627
+ }
628
+ function safeEmit(sink, event) {
629
+ try {
630
+ sink.emit(event);
631
+ } catch {
632
+ }
633
+ }
634
+ function unblocked(resolution) {
635
+ return resolution === "approved" || resolution === "executed";
636
+ }
637
+ function terminalMissionEvent(missionId, status) {
638
+ const terminal = status === "succeeded" || status === "failed" || status === "aborted" || status === "cancelled" ? status : "failed";
639
+ return {
640
+ type: "mission.completed",
641
+ missionId,
642
+ at: Date.now(),
643
+ ok: terminal === "succeeded",
644
+ status: terminal,
645
+ ...terminal === "succeeded" ? {} : { summary: `Mission ${status}` }
646
+ };
647
+ }
648
+ function createMissionEngine(options) {
649
+ const { service, estimateStepCostUsd, gates } = options;
650
+ const sink = options.sink ?? noopEventSink;
651
+ const nonFatalStepKinds = new Set(options.nonFatalStepKinds ?? DEFAULT_NON_FATAL_STEP_KINDS);
652
+ const externalActionCap = gates?.externalActionCap ?? DEFAULT_EXTERNAL_ACTION_CAP;
653
+ function isFatalStepKind(kind) {
654
+ return !nonFatalStepKinds.has(kind);
655
+ }
656
+ function rejectStep(failure) {
657
+ if (failure.conflict) throw new MissionConcurrencyError(failure.error);
658
+ return { kind: "failed", error: failure.error, fatal: true };
659
+ }
660
+ function isStepCurrentOrFuture(mission, stepId) {
661
+ const index = mission.plan.findIndex((candidate) => candidate.id === stepId);
662
+ return index >= mission.cursor;
663
+ }
664
+ const recordCost = async (missionId, deltaUsd, ledgerDelta) => {
665
+ const recorded = await service.addCost(missionId, deltaUsd, ledgerDelta);
666
+ if (!recorded.succeeded) return recorded;
667
+ safeEmit(sink, {
668
+ type: "cost.updated",
669
+ missionId,
670
+ at: Date.now(),
671
+ spentUsd: recorded.value.spentUsd,
672
+ capUsd: recorded.value.budgetUsd
673
+ });
674
+ return recorded;
675
+ };
676
+ const pauseMission = async (missionId, reason) => {
677
+ const before = await service.getMission(missionId);
678
+ const paused = await service.pause(missionId, reason);
679
+ if (!paused.succeeded) return paused;
680
+ if (before?.status !== "paused") {
681
+ safeEmit(sink, {
682
+ type: "mission.paused",
683
+ missionId,
684
+ at: Date.now(),
685
+ reason: paused.value.pauseReason ?? reason
686
+ });
687
+ }
688
+ return paused;
689
+ };
690
+ const runStep = async (missionId, stepId, dispatch) => {
691
+ const mission = await service.getMission(missionId);
692
+ if (!mission) throw new MissionConcurrencyError(`Mission ${missionId} not found mid-run`);
693
+ const stepIndex = mission.plan.findIndex((candidate) => candidate.id === stepId);
694
+ const step = stepIndex < 0 ? void 0 : mission.plan[stepIndex];
695
+ if (!step) {
696
+ return { kind: "skipped-cursor", reason: `Step ${stepId} is no longer in mission plan` };
697
+ }
698
+ if (isMissionStopRequested(mission)) {
699
+ return { kind: "failed", error: mission.pauseReason ?? "Mission stop requested", fatal: true };
700
+ }
701
+ if (mission.status !== "running") {
702
+ return { kind: "skipped-cursor", reason: mission.pauseReason ?? `Mission is ${mission.status}` };
703
+ }
704
+ if (step.status === "done" && step.resultRef) {
705
+ if (stepIndex === mission.cursor) {
706
+ const reconciled = await service.advanceCursor(missionId);
707
+ if (!reconciled.succeeded) return rejectStep(reconciled);
708
+ }
709
+ safeEmit(sink, { type: "step.completed", missionId, at: Date.now(), stepId, ok: true });
710
+ return { kind: "done", resultRef: step.resultRef, cached: true };
711
+ }
712
+ if (stepIndex < mission.cursor) {
713
+ return { kind: "skipped-cursor", reason: `Step ${stepId} is behind cursor ${mission.cursor}` };
714
+ }
715
+ const startedAt = Date.now();
716
+ if (step.status !== "running") {
717
+ const running = await service.setStepStatus(missionId, stepId, "running");
718
+ if (!running.succeeded) return rejectStep(running);
719
+ }
720
+ safeEmit(sink, { type: "step.started", missionId, at: startedAt, stepId });
721
+ let dispatched;
722
+ try {
723
+ dispatched = await dispatch({ mission, step, stepIndex });
724
+ } catch (error) {
725
+ if (error instanceof RetryableStepError) throw error;
726
+ const latest = await service.getMission(missionId);
727
+ if (latest && !isMissionTerminal(latest.status) && !isMissionStopRequested(latest) && !isStepCurrentOrFuture(latest, stepId)) {
728
+ return { kind: "skipped-cursor", reason: `Step ${stepId} is no longer active` };
729
+ }
730
+ const message = error instanceof Error ? error.message : "Sandbox dispatch failed";
731
+ const failed = await service.setStepStatus(missionId, stepId, "failed", { error: message });
732
+ if (!failed.succeeded) return rejectStep(failed);
733
+ safeEmit(sink, {
734
+ type: "step.completed",
735
+ missionId,
736
+ at: Date.now(),
737
+ stepId,
738
+ ok: false,
739
+ reason: message,
740
+ durationMs: Date.now() - startedAt
741
+ });
742
+ return { kind: "failed", error: message, fatal: isFatalStepKind(step.kind) };
743
+ }
744
+ const afterDispatch = await service.getMission(missionId);
745
+ if (!afterDispatch) throw new MissionConcurrencyError(`Mission ${missionId} not found after dispatch`);
746
+ if (isMissionTerminal(afterDispatch.status) || isMissionStopRequested(afterDispatch)) {
747
+ return { kind: "failed", error: afterDispatch.pauseReason ?? "Mission stop requested", fatal: true };
748
+ }
749
+ if (afterDispatch.status !== "running") {
750
+ return { kind: "skipped-cursor", reason: afterDispatch.pauseReason ?? `Mission is ${afterDispatch.status}` };
751
+ }
752
+ if (!isStepCurrentOrFuture(afterDispatch, stepId)) {
753
+ return { kind: "skipped-cursor", reason: `Step ${stepId} is no longer active` };
754
+ }
755
+ if (dispatched.kind === "in_progress") {
756
+ if (dispatched.sublabel !== void 0) {
757
+ safeEmit(sink, { type: "step.updated", missionId, at: Date.now(), stepId, sublabel: dispatched.sublabel });
758
+ }
759
+ return {
760
+ kind: "in_progress",
761
+ sessionRef: dispatched.sessionRef,
762
+ pollAfterMs: dispatched.pollAfterMs,
763
+ ...dispatched.sublabel === void 0 ? {} : { sublabel: dispatched.sublabel }
764
+ };
765
+ }
766
+ if (dispatched.sublabel !== void 0) {
767
+ safeEmit(sink, { type: "step.updated", missionId, at: Date.now(), stepId, sublabel: dispatched.sublabel });
768
+ }
769
+ {
770
+ const deltaUsd = dispatched.cost?.deltaUsd ?? estimateStepCostUsd(step);
771
+ if (deltaUsd > 0 || dispatched.cost?.ledgerDelta) {
772
+ const cost = await recordCost(missionId, deltaUsd, {
773
+ costUsd: deltaUsd,
774
+ llmCalls: 1,
775
+ ...dispatched.cost?.ledgerDelta ?? {}
776
+ });
777
+ if (!cost.succeeded) return rejectStep(cost);
778
+ }
779
+ }
780
+ const done = await service.setStepStatus(missionId, stepId, "done", {
781
+ resultRef: dispatched.resultRef,
782
+ ...dispatched.sublabel === void 0 ? {} : { sublabel: dispatched.sublabel }
783
+ });
784
+ if (!done.succeeded) return rejectStep(done);
785
+ safeEmit(sink, {
786
+ type: "step.completed",
787
+ missionId,
788
+ at: Date.now(),
789
+ stepId,
790
+ ok: true,
791
+ durationMs: Date.now() - startedAt
792
+ });
793
+ const advanced = await service.advanceCursor(missionId);
794
+ if (!advanced.succeeded) return rejectStep(advanced);
795
+ return { kind: "done", resultRef: dispatched.resultRef, cached: false };
796
+ };
797
+ async function parkForApproval(mission, step, reason) {
798
+ const waiting = await service.markWaitingApproval(mission.id, step.id);
799
+ if (!waiting.succeeded) {
800
+ if (waiting.conflict) throw new MissionConcurrencyError(waiting.error);
801
+ return { kind: "halted", status: mission.status, reason: waiting.error };
802
+ }
803
+ safeEmit(sink, {
804
+ type: "mission.waiting_approval",
805
+ missionId: mission.id,
806
+ at: Date.now(),
807
+ reason
808
+ });
809
+ safeEmit(sink, {
810
+ type: "mission.plan.updated",
811
+ missionId: mission.id,
812
+ at: Date.now(),
813
+ title: waiting.value.summary ?? "Mission",
814
+ steps: waiting.value.plan.map((candidate) => ({
815
+ id: candidate.id,
816
+ intent: candidate.intent,
817
+ kind: candidate.kind,
818
+ status: candidate.status
819
+ })),
820
+ budgetUsd: waiting.value.budgetUsd
821
+ });
822
+ return { kind: "halted", status: "waiting_approval", reason };
823
+ }
824
+ async function enforceBudget(mission, step) {
825
+ const capUsd = mission.budgetUsd;
826
+ if (capUsd === null) return { kind: "continue" };
827
+ const estimatedCostUsd = estimateStepCostUsd(step);
828
+ if (estimatedCostUsd <= 0) return { kind: "continue" };
829
+ if (mission.spentUsd + estimatedCostUsd <= capUsd) return { kind: "continue" };
830
+ if (!gates) {
831
+ const reason = `Budget cap reached before step ${step.id}: $${mission.spentUsd.toFixed(2)} spent of $${capUsd.toFixed(2)}, next step estimated $${estimatedCostUsd.toFixed(2)}`;
832
+ const paused = await pauseMission(mission.id, reason);
833
+ if (!paused.succeeded) {
834
+ if (paused.conflict) throw new MissionConcurrencyError(paused.error);
835
+ return { kind: "halted", status: mission.status, reason: paused.error };
836
+ }
837
+ return { kind: "halted", status: paused.value.status, reason };
838
+ }
839
+ const proposalId = budgetGateProposalId(mission.id, step.id);
840
+ const resolution = await gates.approvals.findResolution(proposalId);
841
+ if (unblocked(resolution)) return { kind: "continue" };
842
+ if (resolution === null) {
843
+ await gates.approvals.createProposal({
844
+ id: proposalId,
845
+ missionId: mission.id,
846
+ stepId: step.id,
847
+ gate: "budget",
848
+ mission,
849
+ step,
850
+ budget: { spentUsd: mission.spentUsd, budgetUsd: capUsd, estimatedCostUsd }
851
+ });
852
+ }
853
+ return parkForApproval(mission, step, `Budget approval required for step ${step.id}`);
854
+ }
855
+ async function enforceVolumeCap(mission, step) {
856
+ if (!gates) return { kind: "continue" };
857
+ const overrideId = volumeGateProposalId(mission.id, step.id);
858
+ const override = await gates.approvals.findResolution(overrideId);
859
+ if (unblocked(override)) return { kind: "continue" };
860
+ const externalCount = await gates.approvals.countExternalActionProposals(mission.id);
861
+ if (externalCount < externalActionCap) return { kind: "continue" };
862
+ if (override === null) {
863
+ await gates.approvals.createProposal({
864
+ id: overrideId,
865
+ missionId: mission.id,
866
+ stepId: step.id,
867
+ gate: "volume",
868
+ mission,
869
+ step,
870
+ volume: { externalActionCount: externalCount, cap: externalActionCap }
871
+ });
872
+ }
873
+ return parkForApproval(mission, step, `External action cap approval required for step ${step.id}`);
874
+ }
875
+ async function enforceStepGate(mission, step) {
876
+ if (!gates) return { kind: "continue" };
877
+ const classification = gates.classifyStep(step);
878
+ if (!classification) return { kind: "continue" };
879
+ if (classification.externalAction) {
880
+ const volume = await enforceVolumeCap(mission, step);
881
+ if (volume.kind === "halted") return volume;
882
+ }
883
+ const proposalId = stepGateProposalId(mission.id, step.id);
884
+ const resolution = await gates.approvals.findResolution(proposalId);
885
+ if (unblocked(resolution)) return { kind: "continue" };
886
+ if (resolution === null) {
887
+ await gates.approvals.createProposal({
888
+ id: proposalId,
889
+ missionId: mission.id,
890
+ stepId: step.id,
891
+ gate: "step",
892
+ mission,
893
+ step,
894
+ classification
895
+ });
896
+ }
897
+ return parkForApproval(mission, step, `Approval required for step ${step.id}`);
898
+ }
899
+ const runPlan = async (missionId, runStepFn, planOptions = {}) => {
900
+ const mission = await service.getMission(missionId);
901
+ if (!mission) return { kind: "not-found" };
902
+ if (isMissionTerminal(mission.status)) {
903
+ safeEmit(sink, terminalMissionEvent(missionId, mission.status));
904
+ return { kind: "terminal", status: mission.status };
905
+ }
906
+ while (true) {
907
+ const currentMission = await service.getMission(missionId);
908
+ if (!currentMission) return { kind: "not-found" };
909
+ if (isMissionTerminal(currentMission.status)) {
910
+ safeEmit(sink, terminalMissionEvent(missionId, currentMission.status));
911
+ return { kind: "terminal", status: currentMission.status };
912
+ }
913
+ if (isMissionStopRequested(currentMission)) {
914
+ return {
915
+ kind: "halted",
916
+ status: currentMission.status,
917
+ reason: currentMission.pauseReason ?? "Mission stop requested"
918
+ };
919
+ }
920
+ if (currentMission.status !== "running") {
921
+ return {
922
+ kind: "halted",
923
+ status: currentMission.status,
924
+ reason: currentMission.pauseReason
925
+ };
926
+ }
927
+ const index = currentMission.cursor;
928
+ if (index >= currentMission.plan.length) break;
929
+ const step = currentMission.plan[index];
930
+ if (!step) break;
931
+ const haltReason = await planOptions.beforeStep?.(currentMission, step);
932
+ if (haltReason) {
933
+ const paused = await pauseMission(missionId, haltReason);
934
+ if (!paused.succeeded) throw new MissionConcurrencyError(paused.error);
935
+ return { kind: "halted", status: paused.value.status, reason: paused.value.pauseReason ?? haltReason };
936
+ }
937
+ if (step.status !== "done") {
938
+ const budget = await enforceBudget(currentMission, step);
939
+ if (budget.kind === "halted") {
940
+ return { kind: "halted", status: budget.status, reason: budget.reason };
941
+ }
942
+ const gate = await enforceStepGate(currentMission, step);
943
+ if (gate.kind === "halted") {
944
+ return { kind: "halted", status: gate.status, reason: gate.reason };
945
+ }
946
+ }
947
+ const outcome = await runStepFn(step, index);
948
+ if (outcome.kind === "failed" && outcome.fatal) {
949
+ safeEmit(sink, {
950
+ type: "mission.completed",
951
+ missionId,
952
+ at: Date.now(),
953
+ ok: false,
954
+ summary: `Step ${step.id} failed: ${outcome.error}`
955
+ });
956
+ return { kind: "failed", failedStepId: step.id, error: outcome.error };
957
+ }
958
+ if (outcome.kind === "failed" && !outcome.fatal) {
959
+ const advanced = await service.advanceCursor(missionId);
960
+ if (!advanced.succeeded) throw new MissionConcurrencyError(advanced.error);
961
+ }
962
+ if (outcome.kind === "in_progress") {
963
+ return {
964
+ kind: "in_progress",
965
+ stepId: step.id,
966
+ sessionRef: outcome.sessionRef,
967
+ pollAfterMs: outcome.pollAfterMs,
968
+ ...outcome.sublabel === void 0 ? {} : { sublabel: outcome.sublabel }
969
+ };
970
+ }
971
+ }
972
+ const finalMission = await service.getMission(missionId);
973
+ const planLength = finalMission?.plan.length ?? 0;
974
+ const summary = `Completed ${planLength} step${planLength === 1 ? "" : "s"}`;
975
+ const completed = await service.complete(missionId, { ok: true, summary });
976
+ if (!completed.succeeded) {
977
+ const after = await service.getMission(missionId);
978
+ if (after && isMissionTerminal(after.status)) {
979
+ safeEmit(sink, terminalMissionEvent(missionId, after.status));
980
+ return { kind: "terminal", status: after.status };
981
+ }
982
+ throw new MissionConcurrencyError(completed.error);
983
+ }
984
+ safeEmit(sink, { type: "mission.completed", missionId, at: Date.now(), ok: true, summary });
985
+ return { kind: "completed", summary };
986
+ };
987
+ return { runStep, runPlan, recordCost, pauseMission };
988
+ }
989
+
990
+ // src/missions/plan-parse.ts
991
+ var DEFAULT_MISSION_STEP_KINDS = [
992
+ "research",
993
+ "generate",
994
+ "analyze",
995
+ "write",
996
+ "best-effort"
997
+ ];
998
+ function parseMissionBlocks(fullContent, options = {}) {
999
+ const kinds = new Set(options.kinds ?? DEFAULT_MISSION_STEP_KINDS);
1000
+ const missionRegex = /:::mission\s*\n([\s\S]*?)\n\s*:::/g;
1001
+ const missions = [];
1002
+ let match;
1003
+ while ((match = missionRegex.exec(fullContent)) !== null) {
1004
+ const body = match[1];
1005
+ if (body === void 0) continue;
1006
+ const parsed = parseMissionBody(body, kinds);
1007
+ if (parsed) missions.push(parsed);
1008
+ }
1009
+ return missions;
1010
+ }
1011
+ function parseMissionBody(body, kinds) {
1012
+ let title = null;
1013
+ const steps = [];
1014
+ for (const rawLine of body.split("\n")) {
1015
+ const line = rawLine.trim();
1016
+ if (!line) continue;
1017
+ const titleMatch = /^title\s*:\s*(.+)$/i.exec(line);
1018
+ if (titleMatch?.[1] !== void 0) {
1019
+ if (title === null) title = titleMatch[1].trim();
1020
+ continue;
1021
+ }
1022
+ const stepMatch = /^([A-Za-z0-9][A-Za-z0-9_-]*)\s*:\s*([A-Za-z-]+)\s*\|\s*(.+)$/.exec(line);
1023
+ if (!stepMatch) continue;
1024
+ const id = stepMatch[1]?.trim();
1025
+ const kind = stepMatch[2]?.trim().toLowerCase();
1026
+ const intent = stepMatch[3]?.trim();
1027
+ if (!id || !kind || !intent) continue;
1028
+ if (!kinds.has(kind)) continue;
1029
+ steps.push({ id, kind, intent });
1030
+ }
1031
+ if (!title || steps.length === 0) return null;
1032
+ return { title, steps };
1033
+ }
1034
+ function buildAgentMissionPlan(steps) {
1035
+ if (steps.length === 0) throw new Error("mission plan must have at least one step");
1036
+ const seen = /* @__PURE__ */ new Set();
1037
+ for (const step of steps) {
1038
+ if (seen.has(step.id)) {
1039
+ throw new Error(`duplicate mission step id "${step.id}" \u2014 step ids must be unique`);
1040
+ }
1041
+ seen.add(step.id);
1042
+ }
1043
+ return steps.map((step) => ({
1044
+ id: step.id,
1045
+ intent: step.intent,
1046
+ kind: step.kind,
1047
+ status: "pending",
1048
+ attempts: 0
1049
+ }));
1050
+ }
1051
+
1052
+ export {
1053
+ isMissionTerminal,
1054
+ isMissionStopRequested,
1055
+ createMissionService,
1056
+ createInMemoryMissionStore,
1057
+ noopEventSink,
1058
+ MISSION_CONTROL_CHANNEL_ID,
1059
+ parseSessionStreamEnvelope,
1060
+ asMissionStreamEvent,
1061
+ applyMissionEvent,
1062
+ mergeMissionState,
1063
+ reduceMissionEvents,
1064
+ MissionConcurrencyError,
1065
+ RetryableStepError,
1066
+ stepGateProposalId,
1067
+ budgetGateProposalId,
1068
+ volumeGateProposalId,
1069
+ createMissionEngine,
1070
+ DEFAULT_MISSION_STEP_KINDS,
1071
+ parseMissionBlocks,
1072
+ buildAgentMissionPlan
1073
+ };
1074
+ //# sourceMappingURL=chunk-UIWB2F6N.js.map