mcp-codex-worker 0.1.11 → 0.1.13

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.
Files changed (73) hide show
  1. package/dist/src/execution/base-adapter.d.ts +63 -0
  2. package/dist/src/execution/base-adapter.js +74 -0
  3. package/dist/src/execution/base-adapter.js.map +1 -0
  4. package/dist/src/execution/claude-adapter.d.ts +12 -0
  5. package/dist/src/execution/claude-adapter.js +23 -0
  6. package/dist/src/execution/claude-adapter.js.map +1 -0
  7. package/dist/src/execution/codex-adapter.d.ts +21 -0
  8. package/dist/src/execution/codex-adapter.js +35 -0
  9. package/dist/src/execution/codex-adapter.js.map +1 -0
  10. package/dist/src/execution/codex-pause-flow.d.ts +23 -0
  11. package/dist/src/execution/codex-pause-flow.js +185 -0
  12. package/dist/src/execution/codex-pause-flow.js.map +1 -0
  13. package/dist/src/execution/copilot-adapter.d.ts +12 -0
  14. package/dist/src/execution/copilot-adapter.js +23 -0
  15. package/dist/src/execution/copilot-adapter.js.map +1 -0
  16. package/dist/src/execution/provider-capabilities.d.ts +35 -0
  17. package/dist/src/execution/provider-capabilities.js +58 -0
  18. package/dist/src/execution/provider-capabilities.js.map +1 -0
  19. package/dist/src/execution/provider-registry.d.ts +37 -0
  20. package/dist/src/execution/provider-registry.js +69 -0
  21. package/dist/src/execution/provider-registry.js.map +1 -0
  22. package/dist/src/mcp/resource-renderers.d.ts +25 -0
  23. package/dist/src/mcp/resource-renderers.js +190 -0
  24. package/dist/src/mcp/resource-renderers.js.map +1 -0
  25. package/dist/src/mcp/sep1686-handlers.d.ts +56 -0
  26. package/dist/src/mcp/sep1686-handlers.js +98 -0
  27. package/dist/src/mcp/sep1686-handlers.js.map +1 -0
  28. package/dist/src/mcp/tool-definitions.d.ts +157 -0
  29. package/dist/src/mcp/tool-definitions.js +242 -0
  30. package/dist/src/mcp/tool-definitions.js.map +1 -1
  31. package/dist/src/task/fsm-transitions.d.ts +17 -0
  32. package/dist/src/task/fsm-transitions.js +66 -0
  33. package/dist/src/task/fsm-transitions.js.map +1 -0
  34. package/dist/src/task/task-handle-impl.d.ts +10 -0
  35. package/dist/src/task/task-handle-impl.js +139 -0
  36. package/dist/src/task/task-handle-impl.js.map +1 -0
  37. package/dist/src/task/task-handle.d.ts +88 -0
  38. package/dist/src/task/task-handle.js +2 -0
  39. package/dist/src/task/task-handle.js.map +1 -0
  40. package/dist/src/task/task-manager.d.ts +99 -0
  41. package/dist/src/task/task-manager.js +246 -0
  42. package/dist/src/task/task-manager.js.map +1 -0
  43. package/dist/src/task/task-persistence.d.ts +18 -0
  44. package/dist/src/task/task-persistence.js +61 -0
  45. package/dist/src/task/task-persistence.js.map +1 -0
  46. package/dist/src/task/task-state.d.ts +79 -0
  47. package/dist/src/task/task-state.js +24 -0
  48. package/dist/src/task/task-state.js.map +1 -0
  49. package/dist/src/task/task-store.d.ts +46 -0
  50. package/dist/src/task/task-store.js +104 -0
  51. package/dist/src/task/task-store.js.map +1 -0
  52. package/dist/src/task/wire-state-mapper.d.ts +21 -0
  53. package/dist/src/task/wire-state-mapper.js +63 -0
  54. package/dist/src/task/wire-state-mapper.js.map +1 -0
  55. package/package.json +2 -1
  56. package/src/execution/base-adapter.ts +133 -0
  57. package/src/execution/claude-adapter.ts +40 -0
  58. package/src/execution/codex-adapter.ts +67 -0
  59. package/src/execution/codex-pause-flow.ts +225 -0
  60. package/src/execution/copilot-adapter.ts +40 -0
  61. package/src/execution/provider-capabilities.ts +100 -0
  62. package/src/execution/provider-registry.ts +81 -0
  63. package/src/mcp/resource-renderers.ts +224 -0
  64. package/src/mcp/sep1686-handlers.ts +149 -0
  65. package/src/mcp/tool-definitions.ts +255 -0
  66. package/src/task/fsm-transitions.ts +72 -0
  67. package/src/task/task-handle-impl.ts +170 -0
  68. package/src/task/task-handle.ts +135 -0
  69. package/src/task/task-manager.ts +328 -0
  70. package/src/task/task-persistence.ts +95 -0
  71. package/src/task/task-state.ts +121 -0
  72. package/src/task/task-store.ts +121 -0
  73. package/src/task/wire-state-mapper.ts +77 -0
@@ -0,0 +1,328 @@
1
+ import {
2
+ uniqueNamesGenerator,
3
+ adjectives,
4
+ animals,
5
+ } from 'unique-names-generator';
6
+
7
+ import { TaskStatus, isTerminalStatus } from './task-state.js';
8
+ import type {
9
+ TaskState,
10
+ TaskTypeName,
11
+ Provider,
12
+ PendingQuestion,
13
+ } from './task-state.js';
14
+ import { TaskStore } from './task-store.js';
15
+ import type { TaskHandle } from './task-handle.js';
16
+ import type { TaskResult, SessionMetrics } from './task-handle.js';
17
+ import { createTaskHandle } from './task-handle-impl.js';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Configuration
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /** Maximum lines retained in the in-memory output ring buffer. */
24
+ const OUTPUT_RING_BUFFER_MAX = 500;
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Public types
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export interface TaskManagerOptions {
31
+ /**
32
+ * Root directory for persistence files.
33
+ * `undefined` means persistence is disabled (in-memory only).
34
+ */
35
+ persistenceRoot: string | undefined;
36
+ }
37
+
38
+ export interface CreateTaskInput {
39
+ prompt: string;
40
+ cwd: string;
41
+ provider: Provider;
42
+ taskType: TaskTypeName;
43
+ model?: string;
44
+ timeoutMs?: number;
45
+ dependsOn?: string[];
46
+ labels?: string[];
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Event listener types
51
+ // ---------------------------------------------------------------------------
52
+
53
+ export type StatusChangeListener = (
54
+ task: TaskState,
55
+ previousStatus: TaskStatus,
56
+ ) => void;
57
+
58
+ export type OutputListener = (taskId: string, line: string) => void;
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // TaskManager
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /**
65
+ * Central orchestrator for task lifecycle management.
66
+ *
67
+ * Wraps {@link TaskStore} with:
68
+ * - Human-readable ID generation (adjective-animal-number)
69
+ * - {@link TaskHandle} creation for provider adapters
70
+ * - Event bus (status changes, output lines)
71
+ * - Output ring buffer capping
72
+ *
73
+ * Spec reference: §3.1 (layer model), §3.2 (TaskHandle)
74
+ */
75
+ export class TaskManager {
76
+ private readonly store = new TaskStore();
77
+ private readonly handles = new Map<string, TaskHandle>();
78
+ private readonly abortControllers = new Map<string, AbortController>();
79
+ private readonly abortListeners = new Map<string, Set<() => void>>();
80
+
81
+ private readonly statusListeners = new Set<StatusChangeListener>();
82
+ private readonly outputListeners = new Set<OutputListener>();
83
+
84
+ readonly persistenceRoot: string | undefined;
85
+
86
+ constructor(options: TaskManagerOptions) {
87
+ this.persistenceRoot = options.persistenceRoot;
88
+ }
89
+
90
+ // -------------------------------------------------------------------------
91
+ // Task creation
92
+ // -------------------------------------------------------------------------
93
+
94
+ /**
95
+ * Create a new task with a human-readable ID, store it, and return
96
+ * the initial {@link TaskState}.
97
+ */
98
+ createTask(input: CreateTaskInput): TaskState {
99
+ const id = this.generateId();
100
+ const now = new Date().toISOString();
101
+
102
+ const task: TaskState = {
103
+ id,
104
+ status: TaskStatus.PENDING,
105
+ provider: input.provider,
106
+ taskType: input.taskType,
107
+ prompt: input.prompt,
108
+ cwd: input.cwd,
109
+ createdAt: now,
110
+ updatedAt: now,
111
+ labels: input.labels ?? [],
112
+ output: [],
113
+ pendingQuestions: [],
114
+ };
115
+
116
+ // Only set optional properties when defined (exactOptionalPropertyTypes)
117
+ if (input.model !== undefined) task.model = input.model;
118
+ if (input.timeoutMs !== undefined) task.timeoutMs = input.timeoutMs;
119
+ if (input.dependsOn !== undefined) task.dependsOn = input.dependsOn;
120
+
121
+ this.store.create(task);
122
+
123
+ // Pre-create the handle so getHandle never returns undefined for a known task
124
+ const handle = createTaskHandle(this, id);
125
+ this.handles.set(id, handle);
126
+
127
+ return task;
128
+ }
129
+
130
+ // -------------------------------------------------------------------------
131
+ // Lookups
132
+ // -------------------------------------------------------------------------
133
+
134
+ getTask(id: string): TaskState | undefined {
135
+ return this.store.get(id);
136
+ }
137
+
138
+ getBySessionId(sessionId: string): TaskState | undefined {
139
+ return this.store.getBySessionId(sessionId);
140
+ }
141
+
142
+ getHandle(id: string): TaskHandle | undefined {
143
+ return this.handles.get(id);
144
+ }
145
+
146
+ getAllTasks(): TaskState[] {
147
+ return this.store.getAll();
148
+ }
149
+
150
+ // -------------------------------------------------------------------------
151
+ // Mutations (called by TaskHandle implementation)
152
+ // -------------------------------------------------------------------------
153
+
154
+ /**
155
+ * Apply a partial update to a task.
156
+ *
157
+ * If the update includes a status change, the FSM is validated by the store.
158
+ * On successful status change the event bus is notified.
159
+ */
160
+ updateTask(id: string, updates: Partial<TaskState>): TaskState | undefined {
161
+ const prev = this.store.get(id);
162
+ if (!prev) return undefined;
163
+
164
+ const previousStatus = prev.status;
165
+ const result = this.store.update(id, updates);
166
+
167
+ if (result && result.status !== previousStatus) {
168
+ this.emitStatusChange(result, previousStatus);
169
+ }
170
+
171
+ return result;
172
+ }
173
+
174
+ /**
175
+ * Append a line to the task's in-memory output ring buffer.
176
+ * Caps at {@link OUTPUT_RING_BUFFER_MAX} lines (oldest lines evicted).
177
+ */
178
+ appendOutput(id: string, line: string): void {
179
+ const task = this.store.get(id);
180
+ if (!task) return;
181
+
182
+ task.output.push(line);
183
+ if (task.output.length > OUTPUT_RING_BUFFER_MAX) {
184
+ task.output.splice(0, task.output.length - OUTPUT_RING_BUFFER_MAX);
185
+ }
186
+ task.lastOutputAt = new Date().toISOString();
187
+ task.updatedAt = task.lastOutputAt;
188
+
189
+ for (const listener of this.outputListeners) {
190
+ listener(id, line);
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Record a verbose-only output line.
196
+ * Updates lastOutputAt and fires the output listener with a prefix,
197
+ * but does NOT push to the in-memory ring buffer.
198
+ */
199
+ appendOutputFileOnly(id: string, line: string): void {
200
+ const task = this.store.get(id);
201
+ if (!task) return;
202
+
203
+ task.lastOutputAt = new Date().toISOString();
204
+ task.updatedAt = task.lastOutputAt;
205
+
206
+ for (const listener of this.outputListeners) {
207
+ listener(id, `[verbose-only] ${line}`);
208
+ }
209
+ }
210
+
211
+ // -------------------------------------------------------------------------
212
+ // Abort management
213
+ // -------------------------------------------------------------------------
214
+
215
+ registerAbort(id: string, controller: AbortController): void {
216
+ this.abortControllers.set(id, controller);
217
+ }
218
+
219
+ unregisterAbort(id: string): void {
220
+ this.abortControllers.delete(id);
221
+ }
222
+
223
+ getAbortController(id: string): AbortController | undefined {
224
+ return this.abortControllers.get(id);
225
+ }
226
+
227
+ /**
228
+ * Register a callback for when a task's abort controller fires.
229
+ * Returns an unsubscribe function.
230
+ */
231
+ onAborted(id: string, cb: () => void): () => void {
232
+ let listeners = this.abortListeners.get(id);
233
+ if (!listeners) {
234
+ listeners = new Set();
235
+ this.abortListeners.set(id, listeners);
236
+ }
237
+ listeners.add(cb);
238
+ return () => {
239
+ listeners!.delete(cb);
240
+ };
241
+ }
242
+
243
+ /** Fire abort listeners for a task. Called when AbortController signals. */
244
+ fireAbortListeners(id: string): void {
245
+ const listeners = this.abortListeners.get(id);
246
+ if (!listeners) return;
247
+ for (const cb of listeners) {
248
+ cb();
249
+ }
250
+ }
251
+
252
+ // -------------------------------------------------------------------------
253
+ // Pending question queue
254
+ // -------------------------------------------------------------------------
255
+
256
+ queuePendingQuestion(id: string, q: PendingQuestion): void {
257
+ const task = this.store.get(id);
258
+ if (!task) return;
259
+ task.pendingQuestions.push(q);
260
+ task.updatedAt = new Date().toISOString();
261
+ }
262
+
263
+ dequeuePendingQuestion(id: string): PendingQuestion | undefined {
264
+ const task = this.store.get(id);
265
+ if (!task) return undefined;
266
+ const q = task.pendingQuestions.shift();
267
+ if (q !== undefined) {
268
+ task.updatedAt = new Date().toISOString();
269
+ }
270
+ return q;
271
+ }
272
+
273
+ getPendingQuestions(id: string): readonly PendingQuestion[] {
274
+ const task = this.store.get(id);
275
+ if (!task) return [];
276
+ return task.pendingQuestions;
277
+ }
278
+
279
+ // -------------------------------------------------------------------------
280
+ // Event bus
281
+ // -------------------------------------------------------------------------
282
+
283
+ /**
284
+ * Subscribe to status change events.
285
+ * Returns an unsubscribe function.
286
+ */
287
+ onStatusChange(cb: StatusChangeListener): () => void {
288
+ this.statusListeners.add(cb);
289
+ return () => {
290
+ this.statusListeners.delete(cb);
291
+ };
292
+ }
293
+
294
+ /**
295
+ * Subscribe to output line events.
296
+ * Returns an unsubscribe function.
297
+ */
298
+ onOutput(cb: OutputListener): () => void {
299
+ this.outputListeners.add(cb);
300
+ return () => {
301
+ this.outputListeners.delete(cb);
302
+ };
303
+ }
304
+
305
+ /**
306
+ * Emit a status change event to all listeners.
307
+ * Public so that the TaskHandle implementation can call it directly.
308
+ */
309
+ emitStatusChange(task: TaskState, previousStatus: TaskStatus): void {
310
+ for (const listener of this.statusListeners) {
311
+ listener(task, previousStatus);
312
+ }
313
+ }
314
+
315
+ // -------------------------------------------------------------------------
316
+ // Internal helpers
317
+ // -------------------------------------------------------------------------
318
+
319
+ private generateId(): string {
320
+ const name = uniqueNamesGenerator({
321
+ dictionaries: [adjectives, animals],
322
+ separator: '-',
323
+ length: 2,
324
+ });
325
+ const num = Math.floor(Math.random() * 900) + 100; // 100-999
326
+ return `${name}-${num}`;
327
+ }
328
+ }
@@ -0,0 +1,95 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+
5
+ import { TaskStatus, isTerminalStatus } from './task-state.js';
6
+ import type { TaskState } from './task-state.js';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Persisted profile state — cooldown / failure tracking for provider profiles
10
+ // Spec reference: §8.1
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export interface PersistedProfileState {
14
+ id: string;
15
+ configDir: string;
16
+ cooldownUntil?: number;
17
+ failureCount: number;
18
+ lastFailureReason?: string;
19
+ }
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Top-level persisted state envelope
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export interface PersistedState {
26
+ version: 1;
27
+ tasks: TaskState[];
28
+ profiles: PersistedProfileState[];
29
+ lastSavedAt: string;
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // persistenceDir — deterministic per-workspace directory
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export function persistenceDir(root: string, cwd: string): string {
37
+ const hash = createHash('md5').update(cwd).digest('hex');
38
+ return join(root, hash);
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // saveState — atomic write: write tmp → rename
43
+ // ---------------------------------------------------------------------------
44
+
45
+ export async function saveState(dir: string, state: PersistedState): Promise<void> {
46
+ await mkdir(dir, { recursive: true });
47
+ const target = join(dir, 'state.json');
48
+ const tmp = join(dir, `state.json.tmp.${process.pid}`);
49
+ await writeFile(tmp, JSON.stringify(state, null, 2), 'utf-8');
50
+ await rename(tmp, target);
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // loadState — read + parse, null on missing or corrupt
55
+ // ---------------------------------------------------------------------------
56
+
57
+ export async function loadState(dir: string): Promise<PersistedState | null> {
58
+ try {
59
+ const raw = await readFile(join(dir, 'state.json'), 'utf-8');
60
+ return JSON.parse(raw) as PersistedState;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // applyRecovery — per-task recovery on server restart
68
+ // Spec reference: §9.2 steps 1–2
69
+ // ---------------------------------------------------------------------------
70
+
71
+ export function applyRecovery(task: TaskState, hasCrashRecovery: boolean): TaskState {
72
+ // 1. Terminal states restore as-is
73
+ if (isTerminalStatus(task.status)) {
74
+ return task;
75
+ }
76
+
77
+ // 2. Adapters without crash recovery → UNKNOWN
78
+ if (!hasCrashRecovery) {
79
+ const now = new Date().toISOString();
80
+ return {
81
+ ...task,
82
+ status: TaskStatus.UNKNOWN,
83
+ error: `Server restarted; ${task.provider} sessions don't survive restarts`,
84
+ completedAt: now,
85
+ output: [
86
+ ...task.output,
87
+ `[recovery] Task was ${task.status} when server restarted; marked UNKNOWN`,
88
+ ],
89
+ recovered: true,
90
+ };
91
+ }
92
+
93
+ // 3. Has crash recovery (e.g. Codex) — return as-is; adapter handles externally
94
+ return task;
95
+ }
@@ -0,0 +1,121 @@
1
+ export enum TaskStatus {
2
+ WAITING = 'waiting',
3
+ PENDING = 'pending',
4
+ RUNNING = 'running',
5
+ RATE_LIMITED = 'rate_limited',
6
+ WAITING_ANSWER = 'waiting_answer',
7
+ COMPLETED = 'completed',
8
+ FAILED = 'failed',
9
+ TIMED_OUT = 'timed_out',
10
+ CANCELLED = 'cancelled',
11
+ UNKNOWN = 'unknown',
12
+ }
13
+
14
+ export const TERMINAL_STATUSES: ReadonlySet<TaskStatus> = new Set([
15
+ TaskStatus.COMPLETED,
16
+ TaskStatus.FAILED,
17
+ TaskStatus.CANCELLED,
18
+ TaskStatus.TIMED_OUT,
19
+ TaskStatus.UNKNOWN,
20
+ ]);
21
+
22
+ export function isTerminalStatus(status: TaskStatus): boolean {
23
+ return TERMINAL_STATUSES.has(status);
24
+ }
25
+
26
+ export type TaskTypeName = 'coder' | 'planner' | 'tester' | 'researcher' | 'general';
27
+
28
+ export type Provider = 'codex' | 'copilot' | 'claude-cli';
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // PendingQuestion — discriminated union for 5 pause-flow question variants
32
+ // Spec reference: §3.3
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export type PendingQuestion =
36
+ | {
37
+ type: 'user_input';
38
+ requestId: string;
39
+ questions: Array<{
40
+ id: string;
41
+ text: string;
42
+ options?: string[];
43
+ allowFreeform?: boolean;
44
+ }>;
45
+ }
46
+ | {
47
+ type: 'command_approval';
48
+ requestId: string;
49
+ command: string;
50
+ sandboxPolicy?: string;
51
+ }
52
+ | {
53
+ type: 'file_approval';
54
+ requestId: string;
55
+ fileChanges: Array<{ path: string; patch: string }>;
56
+ }
57
+ | {
58
+ type: 'elicitation';
59
+ requestId: string;
60
+ serverName?: string;
61
+ message: string;
62
+ schema?: unknown;
63
+ }
64
+ | {
65
+ type: 'dynamic_tool';
66
+ requestId: string;
67
+ toolName: string;
68
+ arguments: string;
69
+ };
70
+
71
+ export type PendingQuestionType = PendingQuestion['type'];
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // TaskState — complete in-memory representation of a task
75
+ // Spec reference: §3.2 (data model), §4.1 (FSM states)
76
+ // ---------------------------------------------------------------------------
77
+
78
+ export interface TaskState {
79
+ // Identity
80
+ id: string;
81
+ status: TaskStatus;
82
+ provider: Provider;
83
+ taskType: TaskTypeName;
84
+
85
+ // Execution
86
+ prompt: string;
87
+ cwd: string;
88
+ model?: string;
89
+ sessionId?: string;
90
+ operationId?: string;
91
+
92
+ // Lifecycle timestamps
93
+ createdAt: string;
94
+ updatedAt: string;
95
+ startedAt?: string;
96
+ completedAt?: string;
97
+ lastOutputAt?: string;
98
+ timeoutMs?: number;
99
+ timeoutAt?: string;
100
+ keepAlive?: number;
101
+
102
+ // Output
103
+ output: string[];
104
+ outputFilePath?: string;
105
+
106
+ // Dependencies & labels
107
+ dependsOn?: string[];
108
+ labels: string[];
109
+
110
+ // Pause state
111
+ pendingQuestions: PendingQuestion[];
112
+
113
+ // Error tracking
114
+ error?: string;
115
+ exitCode?: number;
116
+ result?: unknown;
117
+
118
+ // Recovery markers
119
+ recovered?: boolean;
120
+ degraded?: boolean;
121
+ }
@@ -0,0 +1,121 @@
1
+ import { isTerminalStatus } from './task-state.js';
2
+ import type { TaskState, TaskStatus } from './task-state.js';
3
+ import { isValidTransition } from './fsm-transitions.js';
4
+
5
+ /**
6
+ * In-memory task store with FSM-validated state transitions.
7
+ *
8
+ * This is the single source of truth for task state. It enforces the FSM at
9
+ * the data layer — illegal transitions are rejected before any mutation occurs.
10
+ *
11
+ * Terminal tasks can be evicted by age to prevent unbounded memory growth.
12
+ */
13
+ export class TaskStore {
14
+ private readonly tasks = new Map<string, TaskState>();
15
+
16
+ /** Store a new task. Overwrites if the ID already exists. */
17
+ create(task: TaskState): void {
18
+ this.tasks.set(task.id, task);
19
+ }
20
+
21
+ /** Retrieve a task by its unique ID. */
22
+ get(id: string): TaskState | undefined {
23
+ return this.tasks.get(id);
24
+ }
25
+
26
+ /** Linear scan for a task matching the given session ID. */
27
+ getBySessionId(sessionId: string): TaskState | undefined {
28
+ for (const task of this.tasks.values()) {
29
+ if (task.sessionId === sessionId) return task;
30
+ }
31
+ return undefined;
32
+ }
33
+
34
+ /** Return all tasks as an array. */
35
+ getAll(): TaskState[] {
36
+ return [...this.tasks.values()];
37
+ }
38
+
39
+ /**
40
+ * Transition a task to a new status.
41
+ *
42
+ * Returns `true` if the transition was valid and applied, `false` if the
43
+ * task was not found or the transition is illegal per the FSM.
44
+ */
45
+ updateStatus(id: string, next: TaskStatus): boolean {
46
+ const task = this.tasks.get(id);
47
+ if (!task) return false;
48
+
49
+ if (!isValidTransition(task.status, next)) return false;
50
+
51
+ const now = new Date().toISOString();
52
+ task.status = next;
53
+ task.updatedAt = now;
54
+
55
+ if (isTerminalStatus(next)) {
56
+ task.completedAt = now;
57
+ }
58
+
59
+ return true;
60
+ }
61
+
62
+ /**
63
+ * Apply a partial update to a task. If the update includes a `status`
64
+ * change, the FSM is validated first — an illegal transition causes the
65
+ * entire update to be rejected (returns `undefined`).
66
+ */
67
+ update(id: string, updates: Partial<TaskState>): TaskState | undefined {
68
+ const task = this.tasks.get(id);
69
+ if (!task) return undefined;
70
+
71
+ // Validate status transition if status is being changed
72
+ if (updates.status !== undefined && updates.status !== task.status) {
73
+ if (!isValidTransition(task.status, updates.status)) return undefined;
74
+ }
75
+
76
+ const now = new Date().toISOString();
77
+
78
+ Object.assign(task, updates, { updatedAt: now });
79
+
80
+ if (updates.status !== undefined && isTerminalStatus(updates.status)) {
81
+ task.completedAt = now;
82
+ }
83
+
84
+ return task;
85
+ }
86
+
87
+ /** Remove a task by ID. Returns `true` if it existed. */
88
+ delete(id: string): boolean {
89
+ return this.tasks.delete(id);
90
+ }
91
+
92
+ /**
93
+ * Remove terminal tasks whose `updatedAt` timestamp is older than
94
+ * `maxAgeMs` milliseconds from now.
95
+ *
96
+ * Returns the number of tasks evicted.
97
+ */
98
+ evict(maxAgeMs: number): number {
99
+ const cutoff = Date.now() - maxAgeMs;
100
+ let count = 0;
101
+
102
+ for (const [id, task] of this.tasks) {
103
+ if (isTerminalStatus(task.status) && new Date(task.updatedAt).getTime() < cutoff) {
104
+ this.tasks.delete(id);
105
+ count++;
106
+ }
107
+ }
108
+
109
+ return count;
110
+ }
111
+
112
+ /** Remove all tasks. */
113
+ clear(): void {
114
+ this.tasks.clear();
115
+ }
116
+
117
+ /** Return the number of stored tasks. */
118
+ size(): number {
119
+ return this.tasks.size;
120
+ }
121
+ }