loki-mode 5.7.2 → 5.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/VERSION +1 -1
- package/api/README.md +297 -0
- package/api/client.ts +377 -0
- package/api/middleware/auth.ts +129 -0
- package/api/middleware/cors.ts +145 -0
- package/api/middleware/error.ts +226 -0
- package/api/mod.ts +58 -0
- package/api/openapi.yaml +614 -0
- package/api/routes/events.ts +165 -0
- package/api/routes/health.ts +169 -0
- package/api/routes/sessions.ts +262 -0
- package/api/routes/tasks.ts +182 -0
- package/api/server.js +637 -0
- package/api/server.ts +328 -0
- package/api/server_test.ts +265 -0
- package/api/services/cli-bridge.ts +503 -0
- package/api/services/event-bus.ts +189 -0
- package/api/services/state-watcher.ts +517 -0
- package/api/test.js +494 -0
- package/api/types/api.ts +122 -0
- package/api/types/events.ts +132 -0
- package/autonomy/loki +28 -2
- package/package.json +3 -2
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State Watcher Service
|
|
3
|
+
*
|
|
4
|
+
* Watches .loki/ directory for state changes and emits events.
|
|
5
|
+
* Uses Deno's built-in file watcher with debouncing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
eventBus,
|
|
10
|
+
emitSessionEvent,
|
|
11
|
+
emitPhaseEvent,
|
|
12
|
+
emitTaskEvent,
|
|
13
|
+
emitLogEvent,
|
|
14
|
+
emitHeartbeat,
|
|
15
|
+
} from "./event-bus.ts";
|
|
16
|
+
import type { Session, Task } from "../types/api.ts";
|
|
17
|
+
|
|
18
|
+
interface WatchedState {
|
|
19
|
+
sessions: Map<string, Session>;
|
|
20
|
+
tasks: Map<string, Task[]>;
|
|
21
|
+
lastModified: Map<string, number>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class StateWatcher {
|
|
25
|
+
private lokiDir: string;
|
|
26
|
+
private watchDir: string;
|
|
27
|
+
private watcher: Deno.FsWatcher | null = null;
|
|
28
|
+
private state: WatchedState;
|
|
29
|
+
private debounceTimers: Map<string, number> = new Map();
|
|
30
|
+
private debounceDelay = 100; // ms
|
|
31
|
+
private heartbeatInterval: number | null = null;
|
|
32
|
+
private startTime: Date;
|
|
33
|
+
|
|
34
|
+
constructor() {
|
|
35
|
+
this.lokiDir = Deno.env.get("LOKI_DIR") ||
|
|
36
|
+
new URL("../../", import.meta.url).pathname.replace(/\/$/, "");
|
|
37
|
+
this.watchDir = `${this.lokiDir}/.loki`;
|
|
38
|
+
this.state = {
|
|
39
|
+
sessions: new Map(),
|
|
40
|
+
tasks: new Map(),
|
|
41
|
+
lastModified: new Map(),
|
|
42
|
+
};
|
|
43
|
+
this.startTime = new Date();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Start watching the .loki directory
|
|
48
|
+
*/
|
|
49
|
+
async start(): Promise<void> {
|
|
50
|
+
// Ensure .loki directory exists
|
|
51
|
+
try {
|
|
52
|
+
await Deno.mkdir(this.watchDir, { recursive: true });
|
|
53
|
+
} catch {
|
|
54
|
+
// Directory may already exist
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Initial state load
|
|
58
|
+
await this.loadInitialState();
|
|
59
|
+
|
|
60
|
+
// Start file watcher
|
|
61
|
+
this.watcher = Deno.watchFs(this.watchDir, { recursive: true });
|
|
62
|
+
|
|
63
|
+
// Start heartbeat
|
|
64
|
+
this.heartbeatInterval = setInterval(() => {
|
|
65
|
+
this.emitHeartbeat();
|
|
66
|
+
}, 10000); // Every 10 seconds
|
|
67
|
+
|
|
68
|
+
// Process watch events
|
|
69
|
+
this.processWatchEvents();
|
|
70
|
+
|
|
71
|
+
console.log(`State watcher started, monitoring: ${this.watchDir}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Stop watching
|
|
76
|
+
*/
|
|
77
|
+
stop(): void {
|
|
78
|
+
if (this.watcher) {
|
|
79
|
+
this.watcher.close();
|
|
80
|
+
this.watcher = null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (this.heartbeatInterval !== null) {
|
|
84
|
+
clearInterval(this.heartbeatInterval);
|
|
85
|
+
this.heartbeatInterval = null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Clear debounce timers
|
|
89
|
+
for (const timer of this.debounceTimers.values()) {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
}
|
|
92
|
+
this.debounceTimers.clear();
|
|
93
|
+
|
|
94
|
+
console.log("State watcher stopped");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get current state snapshot
|
|
99
|
+
*/
|
|
100
|
+
getState(): WatchedState {
|
|
101
|
+
return this.state;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Load initial state from .loki directory
|
|
106
|
+
*/
|
|
107
|
+
private async loadInitialState(): Promise<void> {
|
|
108
|
+
// Load sessions
|
|
109
|
+
try {
|
|
110
|
+
const sessionsDir = `${this.watchDir}/sessions`;
|
|
111
|
+
for await (const entry of Deno.readDir(sessionsDir)) {
|
|
112
|
+
if (entry.isDirectory) {
|
|
113
|
+
await this.loadSession(entry.name);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// Sessions directory may not exist
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Load current state file
|
|
121
|
+
try {
|
|
122
|
+
const stateFile = `${this.watchDir}/state.json`;
|
|
123
|
+
const content = await Deno.readTextFile(stateFile);
|
|
124
|
+
const state = JSON.parse(content);
|
|
125
|
+
|
|
126
|
+
if (state.currentSession) {
|
|
127
|
+
// Emit initial state event
|
|
128
|
+
emitLogEvent(
|
|
129
|
+
"info",
|
|
130
|
+
state.currentSession,
|
|
131
|
+
`State watcher loaded session: ${state.currentSession}`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
// State file may not exist
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Load a specific session
|
|
141
|
+
*/
|
|
142
|
+
private async loadSession(sessionId: string): Promise<void> {
|
|
143
|
+
try {
|
|
144
|
+
const sessionFile = `${this.watchDir}/sessions/${sessionId}/session.json`;
|
|
145
|
+
const content = await Deno.readTextFile(sessionFile);
|
|
146
|
+
const session = JSON.parse(content) as Session;
|
|
147
|
+
this.state.sessions.set(sessionId, session);
|
|
148
|
+
|
|
149
|
+
// Load tasks
|
|
150
|
+
await this.loadTasks(sessionId);
|
|
151
|
+
} catch {
|
|
152
|
+
// Session may not have a valid state file
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Load tasks for a session
|
|
158
|
+
*/
|
|
159
|
+
private async loadTasks(sessionId: string): Promise<void> {
|
|
160
|
+
try {
|
|
161
|
+
const tasksFile = `${this.watchDir}/sessions/${sessionId}/tasks.json`;
|
|
162
|
+
const content = await Deno.readTextFile(tasksFile);
|
|
163
|
+
const data = JSON.parse(content);
|
|
164
|
+
this.state.tasks.set(sessionId, data.tasks || []);
|
|
165
|
+
} catch {
|
|
166
|
+
// Tasks file may not exist
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Process file system watch events
|
|
172
|
+
*/
|
|
173
|
+
private async processWatchEvents(): Promise<void> {
|
|
174
|
+
if (!this.watcher) return;
|
|
175
|
+
|
|
176
|
+
for await (const event of this.watcher) {
|
|
177
|
+
for (const path of event.paths) {
|
|
178
|
+
this.handleFileChange(path, event.kind);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Handle a file change with debouncing
|
|
185
|
+
*/
|
|
186
|
+
private handleFileChange(
|
|
187
|
+
path: string,
|
|
188
|
+
kind: Deno.FsEvent["kind"]
|
|
189
|
+
): void {
|
|
190
|
+
// Debounce rapid changes to the same file
|
|
191
|
+
const existingTimer = this.debounceTimers.get(path);
|
|
192
|
+
if (existingTimer) {
|
|
193
|
+
clearTimeout(existingTimer);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const timer = setTimeout(() => {
|
|
197
|
+
this.debounceTimers.delete(path);
|
|
198
|
+
this.processFileChange(path, kind);
|
|
199
|
+
}, this.debounceDelay);
|
|
200
|
+
|
|
201
|
+
this.debounceTimers.set(path, timer);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Process a debounced file change
|
|
206
|
+
*/
|
|
207
|
+
private async processFileChange(
|
|
208
|
+
path: string,
|
|
209
|
+
kind: Deno.FsEvent["kind"]
|
|
210
|
+
): Promise<void> {
|
|
211
|
+
const relativePath = path.replace(this.watchDir + "/", "");
|
|
212
|
+
|
|
213
|
+
// Skip non-relevant files
|
|
214
|
+
if (!relativePath.endsWith(".json") && !relativePath.endsWith(".log")) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Parse path to determine what changed
|
|
219
|
+
const parts = relativePath.split("/");
|
|
220
|
+
|
|
221
|
+
// Handle session state changes
|
|
222
|
+
if (parts[0] === "sessions" && parts.length >= 3) {
|
|
223
|
+
const sessionId = parts[1];
|
|
224
|
+
const fileName = parts[2];
|
|
225
|
+
|
|
226
|
+
switch (fileName) {
|
|
227
|
+
case "session.json":
|
|
228
|
+
await this.handleSessionChange(sessionId, kind);
|
|
229
|
+
break;
|
|
230
|
+
case "tasks.json":
|
|
231
|
+
await this.handleTasksChange(sessionId, kind);
|
|
232
|
+
break;
|
|
233
|
+
case "phase.json":
|
|
234
|
+
await this.handlePhaseChange(sessionId);
|
|
235
|
+
break;
|
|
236
|
+
case "agents.json":
|
|
237
|
+
await this.handleAgentsChange(sessionId);
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Handle global state changes
|
|
243
|
+
if (relativePath === "state.json") {
|
|
244
|
+
await this.handleGlobalStateChange();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Handle log file changes
|
|
248
|
+
if (relativePath.endsWith(".log")) {
|
|
249
|
+
await this.handleLogChange(path);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Handle session state changes
|
|
255
|
+
*/
|
|
256
|
+
private async handleSessionChange(
|
|
257
|
+
sessionId: string,
|
|
258
|
+
kind: Deno.FsEvent["kind"]
|
|
259
|
+
): Promise<void> {
|
|
260
|
+
if (kind === "remove") {
|
|
261
|
+
const oldSession = this.state.sessions.get(sessionId);
|
|
262
|
+
this.state.sessions.delete(sessionId);
|
|
263
|
+
|
|
264
|
+
if (oldSession) {
|
|
265
|
+
emitSessionEvent("session:stopped", sessionId, {
|
|
266
|
+
status: "stopped",
|
|
267
|
+
message: "Session removed",
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
const sessionFile = `${this.watchDir}/sessions/${sessionId}/session.json`;
|
|
275
|
+
const content = await Deno.readTextFile(sessionFile);
|
|
276
|
+
const newSession = JSON.parse(content) as Session;
|
|
277
|
+
const oldSession = this.state.sessions.get(sessionId);
|
|
278
|
+
|
|
279
|
+
this.state.sessions.set(sessionId, newSession);
|
|
280
|
+
|
|
281
|
+
// Detect status changes
|
|
282
|
+
if (!oldSession) {
|
|
283
|
+
emitSessionEvent("session:started", sessionId, {
|
|
284
|
+
status: newSession.status,
|
|
285
|
+
message: "Session created",
|
|
286
|
+
});
|
|
287
|
+
} else if (oldSession.status !== newSession.status) {
|
|
288
|
+
const eventType = this.getSessionEventType(newSession.status);
|
|
289
|
+
emitSessionEvent(eventType, sessionId, {
|
|
290
|
+
status: newSession.status,
|
|
291
|
+
message: `Status changed from ${oldSession.status} to ${newSession.status}`,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Detect phase changes
|
|
296
|
+
if (oldSession && oldSession.currentPhase !== newSession.currentPhase) {
|
|
297
|
+
if (newSession.currentPhase) {
|
|
298
|
+
emitPhaseEvent("phase:started", sessionId, {
|
|
299
|
+
phase: newSession.currentPhase,
|
|
300
|
+
previousPhase: oldSession.currentPhase || undefined,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
} catch (err) {
|
|
305
|
+
console.error(`Error loading session ${sessionId}:`, err);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Handle task changes
|
|
311
|
+
*/
|
|
312
|
+
private async handleTasksChange(sessionId: string): Promise<void> {
|
|
313
|
+
try {
|
|
314
|
+
const tasksFile = `${this.watchDir}/sessions/${sessionId}/tasks.json`;
|
|
315
|
+
const content = await Deno.readTextFile(tasksFile);
|
|
316
|
+
const data = JSON.parse(content);
|
|
317
|
+
const newTasks = data.tasks || [];
|
|
318
|
+
const oldTasks = this.state.tasks.get(sessionId) || [];
|
|
319
|
+
|
|
320
|
+
this.state.tasks.set(sessionId, newTasks);
|
|
321
|
+
|
|
322
|
+
// Detect new tasks
|
|
323
|
+
const oldTaskIds = new Set(oldTasks.map((t: Task) => t.id));
|
|
324
|
+
for (const task of newTasks) {
|
|
325
|
+
if (!oldTaskIds.has(task.id)) {
|
|
326
|
+
emitTaskEvent("task:created", sessionId, {
|
|
327
|
+
taskId: task.id,
|
|
328
|
+
title: task.title || task.subject || "Untitled",
|
|
329
|
+
status: task.status || "pending",
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Detect task status changes
|
|
335
|
+
const oldTaskMap = new Map(oldTasks.map((t: Task) => [t.id, t]));
|
|
336
|
+
for (const task of newTasks) {
|
|
337
|
+
const oldTask = oldTaskMap.get(task.id);
|
|
338
|
+
if (oldTask && oldTask.status !== task.status) {
|
|
339
|
+
const eventType = this.getTaskEventType(task.status);
|
|
340
|
+
emitTaskEvent(eventType, sessionId, {
|
|
341
|
+
taskId: task.id,
|
|
342
|
+
title: task.title || task.subject || "Untitled",
|
|
343
|
+
status: task.status,
|
|
344
|
+
output: task.output,
|
|
345
|
+
error: task.error,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
} catch (err) {
|
|
350
|
+
console.error(`Error loading tasks for ${sessionId}:`, err);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Handle phase changes
|
|
356
|
+
*/
|
|
357
|
+
private async handlePhaseChange(sessionId: string): Promise<void> {
|
|
358
|
+
try {
|
|
359
|
+
const phaseFile = `${this.watchDir}/sessions/${sessionId}/phase.json`;
|
|
360
|
+
const content = await Deno.readTextFile(phaseFile);
|
|
361
|
+
const data = JSON.parse(content);
|
|
362
|
+
|
|
363
|
+
emitPhaseEvent("phase:started", sessionId, {
|
|
364
|
+
phase: data.current,
|
|
365
|
+
previousPhase: data.previous,
|
|
366
|
+
progress: data.progress,
|
|
367
|
+
});
|
|
368
|
+
} catch (err) {
|
|
369
|
+
console.error(`Error loading phase for ${sessionId}:`, err);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Handle agent changes
|
|
375
|
+
*/
|
|
376
|
+
private async handleAgentsChange(sessionId: string): Promise<void> {
|
|
377
|
+
try {
|
|
378
|
+
const agentsFile = `${this.watchDir}/sessions/${sessionId}/agents.json`;
|
|
379
|
+
const content = await Deno.readTextFile(agentsFile);
|
|
380
|
+
const data = JSON.parse(content);
|
|
381
|
+
|
|
382
|
+
// Emit events for active agents
|
|
383
|
+
for (const agent of data.active || []) {
|
|
384
|
+
eventBus.publish("agent:spawned", sessionId, {
|
|
385
|
+
agentId: agent.id,
|
|
386
|
+
type: agent.type,
|
|
387
|
+
model: agent.model,
|
|
388
|
+
task: agent.task,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
} catch (err) {
|
|
392
|
+
console.error(`Error loading agents for ${sessionId}:`, err);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Handle global state changes
|
|
398
|
+
*/
|
|
399
|
+
private async handleGlobalStateChange(): Promise<void> {
|
|
400
|
+
try {
|
|
401
|
+
const stateFile = `${this.watchDir}/state.json`;
|
|
402
|
+
const content = await Deno.readTextFile(stateFile);
|
|
403
|
+
const state = JSON.parse(content);
|
|
404
|
+
|
|
405
|
+
emitLogEvent(
|
|
406
|
+
"info",
|
|
407
|
+
state.currentSession || "global",
|
|
408
|
+
`Global state updated: ${JSON.stringify(state).slice(0, 100)}...`
|
|
409
|
+
);
|
|
410
|
+
} catch (err) {
|
|
411
|
+
console.error("Error loading global state:", err);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Handle log file changes (tail new entries)
|
|
417
|
+
*/
|
|
418
|
+
private async handleLogChange(logPath: string): Promise<void> {
|
|
419
|
+
const lastPos = this.state.lastModified.get(logPath) || 0;
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
const stat = await Deno.stat(logPath);
|
|
423
|
+
const newPos = stat.size;
|
|
424
|
+
|
|
425
|
+
if (newPos > lastPos) {
|
|
426
|
+
// Read new content
|
|
427
|
+
const file = await Deno.open(logPath, { read: true });
|
|
428
|
+
await file.seek(lastPos, Deno.SeekMode.Start);
|
|
429
|
+
|
|
430
|
+
const buffer = new Uint8Array(newPos - lastPos);
|
|
431
|
+
await file.read(buffer);
|
|
432
|
+
file.close();
|
|
433
|
+
|
|
434
|
+
const newContent = new TextDecoder().decode(buffer);
|
|
435
|
+
const lines = newContent.split("\n").filter((l) => l.trim());
|
|
436
|
+
|
|
437
|
+
// Extract session ID from path
|
|
438
|
+
const match = logPath.match(/sessions\/([^/]+)/);
|
|
439
|
+
const sessionId = match ? match[1] : "global";
|
|
440
|
+
|
|
441
|
+
for (const line of lines) {
|
|
442
|
+
emitLogEvent("info", sessionId, line);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
this.state.lastModified.set(logPath, newPos);
|
|
446
|
+
}
|
|
447
|
+
} catch {
|
|
448
|
+
// Log file may have been deleted
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Emit heartbeat with current stats
|
|
454
|
+
*/
|
|
455
|
+
private emitHeartbeat(): void {
|
|
456
|
+
const uptime = Date.now() - this.startTime.getTime();
|
|
457
|
+
let activeAgents = 0;
|
|
458
|
+
let queuedTasks = 0;
|
|
459
|
+
|
|
460
|
+
for (const session of this.state.sessions.values()) {
|
|
461
|
+
if (session.status === "running") {
|
|
462
|
+
const tasks = this.state.tasks.get(session.id) || [];
|
|
463
|
+
queuedTasks += tasks.filter(
|
|
464
|
+
(t) => t.status === "pending" || t.status === "queued"
|
|
465
|
+
).length;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Get active session for heartbeat
|
|
470
|
+
const activeSessions = Array.from(this.state.sessions.values()).filter(
|
|
471
|
+
(s) => s.status === "running"
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
for (const session of activeSessions) {
|
|
475
|
+
emitHeartbeat(session.id, { uptime, activeAgents, queuedTasks });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Map session status to event type
|
|
481
|
+
*/
|
|
482
|
+
private getSessionEventType(
|
|
483
|
+
status: string
|
|
484
|
+
): "session:started" | "session:paused" | "session:resumed" | "session:stopped" | "session:completed" | "session:failed" {
|
|
485
|
+
const map: Record<string, "session:started" | "session:paused" | "session:resumed" | "session:stopped" | "session:completed" | "session:failed"> = {
|
|
486
|
+
starting: "session:started",
|
|
487
|
+
running: "session:resumed",
|
|
488
|
+
paused: "session:paused",
|
|
489
|
+
stopping: "session:stopped",
|
|
490
|
+
stopped: "session:stopped",
|
|
491
|
+
completed: "session:completed",
|
|
492
|
+
failed: "session:failed",
|
|
493
|
+
};
|
|
494
|
+
return map[status] || "session:started";
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Map task status to event type
|
|
499
|
+
*/
|
|
500
|
+
private getTaskEventType(
|
|
501
|
+
status: string
|
|
502
|
+
): "task:created" | "task:started" | "task:progress" | "task:completed" | "task:failed" {
|
|
503
|
+
const map: Record<string, "task:created" | "task:started" | "task:progress" | "task:completed" | "task:failed"> = {
|
|
504
|
+
pending: "task:created",
|
|
505
|
+
queued: "task:created",
|
|
506
|
+
running: "task:started",
|
|
507
|
+
"in progress": "task:started",
|
|
508
|
+
completed: "task:completed",
|
|
509
|
+
done: "task:completed",
|
|
510
|
+
failed: "task:failed",
|
|
511
|
+
};
|
|
512
|
+
return map[status.toLowerCase()] || "task:progress";
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Singleton instance
|
|
517
|
+
export const stateWatcher = new StateWatcher();
|