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/api/server.ts ADDED
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Loki Mode API Server
3
+ *
4
+ * HTTP/SSE server for loki-mode remote control and monitoring.
5
+ *
6
+ * Usage:
7
+ * deno run --allow-net --allow-read --allow-write --allow-env --allow-run api/server.ts
8
+ *
9
+ * Or via CLI:
10
+ * loki serve [--port 8420] [--host localhost]
11
+ */
12
+
13
+ import { stateWatcher } from "./services/state-watcher.ts";
14
+ import { corsMiddleware } from "./middleware/cors.ts";
15
+ import { authMiddleware } from "./middleware/auth.ts";
16
+ import { errorMiddleware } from "./middleware/error.ts";
17
+ import { LokiApiError, ErrorCodes } from "./middleware/error.ts";
18
+
19
+ // Import routes
20
+ import {
21
+ startSession,
22
+ listSessions,
23
+ getSession,
24
+ stopSession,
25
+ pauseSession,
26
+ resumeSession,
27
+ injectInput,
28
+ deleteSession,
29
+ } from "./routes/sessions.ts";
30
+ import {
31
+ listTasks,
32
+ getTask,
33
+ listAllTasks,
34
+ getActiveTasks,
35
+ getQueuedTasks,
36
+ } from "./routes/tasks.ts";
37
+ import {
38
+ streamEvents,
39
+ getEventHistory,
40
+ getEventStats,
41
+ } from "./routes/events.ts";
42
+ import {
43
+ healthCheck,
44
+ readinessCheck,
45
+ livenessCheck,
46
+ detailedStatus,
47
+ } from "./routes/health.ts";
48
+
49
+ // Server configuration
50
+ interface ServerConfig {
51
+ port: number;
52
+ host: string;
53
+ cors: boolean;
54
+ auth: boolean;
55
+ }
56
+
57
+ const defaultConfig: ServerConfig = {
58
+ port: parseInt(Deno.env.get("LOKI_API_PORT") || "8420", 10),
59
+ host: Deno.env.get("LOKI_API_HOST") || "localhost",
60
+ cors: true,
61
+ auth: true,
62
+ };
63
+
64
+ /**
65
+ * Route handler type
66
+ */
67
+ type RouteHandler = (
68
+ req: Request,
69
+ ...params: string[]
70
+ ) => Promise<Response> | Response;
71
+
72
+ /**
73
+ * Route definition
74
+ */
75
+ interface Route {
76
+ method: string;
77
+ pattern: RegExp;
78
+ handler: RouteHandler;
79
+ }
80
+
81
+ /**
82
+ * Define all routes
83
+ */
84
+ const routes: Route[] = [
85
+ // Health endpoints (no auth required)
86
+ { method: "GET", pattern: /^\/health$/, handler: healthCheck },
87
+ { method: "GET", pattern: /^\/health\/ready$/, handler: readinessCheck },
88
+ { method: "GET", pattern: /^\/health\/live$/, handler: livenessCheck },
89
+
90
+ // Status endpoint
91
+ { method: "GET", pattern: /^\/api\/status$/, handler: detailedStatus },
92
+
93
+ // Session endpoints
94
+ { method: "POST", pattern: /^\/api\/sessions$/, handler: startSession },
95
+ { method: "GET", pattern: /^\/api\/sessions$/, handler: listSessions },
96
+ {
97
+ method: "GET",
98
+ pattern: /^\/api\/sessions\/([^/]+)$/,
99
+ handler: getSession,
100
+ },
101
+ {
102
+ method: "POST",
103
+ pattern: /^\/api\/sessions\/([^/]+)\/stop$/,
104
+ handler: stopSession,
105
+ },
106
+ {
107
+ method: "POST",
108
+ pattern: /^\/api\/sessions\/([^/]+)\/pause$/,
109
+ handler: pauseSession,
110
+ },
111
+ {
112
+ method: "POST",
113
+ pattern: /^\/api\/sessions\/([^/]+)\/resume$/,
114
+ handler: resumeSession,
115
+ },
116
+ {
117
+ method: "POST",
118
+ pattern: /^\/api\/sessions\/([^/]+)\/input$/,
119
+ handler: injectInput,
120
+ },
121
+ {
122
+ method: "DELETE",
123
+ pattern: /^\/api\/sessions\/([^/]+)$/,
124
+ handler: deleteSession,
125
+ },
126
+
127
+ // Task endpoints
128
+ { method: "GET", pattern: /^\/api\/tasks$/, handler: listAllTasks },
129
+ { method: "GET", pattern: /^\/api\/tasks\/active$/, handler: getActiveTasks },
130
+ { method: "GET", pattern: /^\/api\/tasks\/queue$/, handler: getQueuedTasks },
131
+ {
132
+ method: "GET",
133
+ pattern: /^\/api\/sessions\/([^/]+)\/tasks$/,
134
+ handler: listTasks,
135
+ },
136
+ {
137
+ method: "GET",
138
+ pattern: /^\/api\/sessions\/([^/]+)\/tasks\/([^/]+)$/,
139
+ handler: getTask,
140
+ },
141
+
142
+ // Event endpoints
143
+ { method: "GET", pattern: /^\/api\/events$/, handler: streamEvents },
144
+ {
145
+ method: "GET",
146
+ pattern: /^\/api\/events\/history$/,
147
+ handler: getEventHistory,
148
+ },
149
+ { method: "GET", pattern: /^\/api\/events\/stats$/, handler: getEventStats },
150
+ ];
151
+
152
+ /**
153
+ * Route a request to the appropriate handler
154
+ */
155
+ async function routeRequest(req: Request): Promise<Response> {
156
+ const url = new URL(req.url);
157
+ const path = url.pathname;
158
+ const method = req.method;
159
+
160
+ // Find matching route
161
+ for (const route of routes) {
162
+ if (route.method !== method && method !== "OPTIONS") {
163
+ continue;
164
+ }
165
+
166
+ const match = path.match(route.pattern);
167
+ if (match) {
168
+ // Extract path parameters
169
+ const params = match.slice(1);
170
+ return route.handler(req, ...params);
171
+ }
172
+ }
173
+
174
+ // No route matched
175
+ throw new LokiApiError(
176
+ `No route found for ${method} ${path}`,
177
+ ErrorCodes.NOT_FOUND
178
+ );
179
+ }
180
+
181
+ /**
182
+ * Create the main request handler with middleware
183
+ */
184
+ function createHandler(config: ServerConfig): Deno.ServeHandler {
185
+ let handler: (req: Request) => Promise<Response> = routeRequest;
186
+
187
+ // Apply middleware in reverse order (innermost first)
188
+ handler = errorMiddleware(handler);
189
+
190
+ if (config.auth) {
191
+ // Skip auth for health endpoints
192
+ const authHandler = handler;
193
+ handler = async (req: Request) => {
194
+ const url = new URL(req.url);
195
+ if (url.pathname.startsWith("/health")) {
196
+ return authHandler(req);
197
+ }
198
+ return authMiddleware(authHandler)(req);
199
+ };
200
+ }
201
+
202
+ if (config.cors) {
203
+ handler = corsMiddleware(handler);
204
+ }
205
+
206
+ return handler;
207
+ }
208
+
209
+ /**
210
+ * Print startup banner
211
+ */
212
+ function printBanner(config: ServerConfig): void {
213
+ console.log(`
214
+ ╔═══════════════════════════════════════════════════════════════╗
215
+ ║ LOKI MODE API SERVER ║
216
+ ╠═══════════════════════════════════════════════════════════════╣
217
+ ║ Version: ${Deno.env.get("LOKI_VERSION") || "dev".padEnd(50)}║
218
+ ║ Host: ${config.host.padEnd(50)}║
219
+ ║ Port: ${String(config.port).padEnd(50)}║
220
+ ║ CORS: ${(config.cors ? "enabled" : "disabled").padEnd(50)}║
221
+ ║ Auth: ${(config.auth ? "enabled (localhost bypass)" : "disabled").padEnd(50)}║
222
+ ╠═══════════════════════════════════════════════════════════════╣
223
+ ║ Endpoints: ║
224
+ ║ GET /health - Health check ║
225
+ ║ GET /api/status - Detailed status ║
226
+ ║ GET /api/events - SSE event stream ║
227
+ ║ POST /api/sessions - Start new session ║
228
+ ║ GET /api/sessions - List sessions ║
229
+ ║ GET /api/sessions/:id - Get session details ║
230
+ ║ POST /api/sessions/:id/stop - Stop session ║
231
+ ║ POST /api/sessions/:id/input - Inject input ║
232
+ ║ GET /api/sessions/:id/tasks - List tasks ║
233
+ ╚═══════════════════════════════════════════════════════════════╝
234
+ `);
235
+ }
236
+
237
+ /**
238
+ * Parse command line arguments
239
+ */
240
+ function parseArgs(): ServerConfig {
241
+ const config = { ...defaultConfig };
242
+ const args = Deno.args;
243
+
244
+ for (let i = 0; i < args.length; i++) {
245
+ switch (args[i]) {
246
+ case "--port":
247
+ case "-p":
248
+ config.port = parseInt(args[++i], 10);
249
+ break;
250
+ case "--host":
251
+ case "-h":
252
+ config.host = args[++i];
253
+ break;
254
+ case "--no-cors":
255
+ config.cors = false;
256
+ break;
257
+ case "--no-auth":
258
+ config.auth = false;
259
+ break;
260
+ case "--help":
261
+ console.log(`
262
+ Loki Mode API Server
263
+
264
+ Usage:
265
+ deno run --allow-all api/server.ts [options]
266
+
267
+ Options:
268
+ --port, -p <port> Port to listen on (default: 8420)
269
+ --host, -h <host> Host to bind to (default: localhost)
270
+ --no-cors Disable CORS
271
+ --no-auth Disable authentication
272
+ --help Show this help message
273
+
274
+ Environment Variables:
275
+ LOKI_API_PORT Port (overridden by --port)
276
+ LOKI_API_HOST Host (overridden by --host)
277
+ LOKI_API_TOKEN API token for remote access
278
+ LOKI_DIR Loki installation directory
279
+ LOKI_VERSION Version string
280
+ LOKI_DEBUG Enable debug output
281
+ `);
282
+ Deno.exit(0);
283
+ }
284
+ }
285
+
286
+ return config;
287
+ }
288
+
289
+ /**
290
+ * Start the server
291
+ */
292
+ async function main(): Promise<void> {
293
+ const config = parseArgs();
294
+
295
+ // Start state watcher
296
+ await stateWatcher.start();
297
+
298
+ // Print banner
299
+ printBanner(config);
300
+
301
+ // Create handler
302
+ const handler = createHandler(config);
303
+
304
+ // Start server
305
+ console.log(`Server listening on http://${config.host}:${config.port}`);
306
+
307
+ await Deno.serve(
308
+ {
309
+ port: config.port,
310
+ hostname: config.host,
311
+ onListen: ({ hostname, port }) => {
312
+ console.log(`Ready to accept connections on ${hostname}:${port}`);
313
+ },
314
+ },
315
+ handler
316
+ ).finished;
317
+ }
318
+
319
+ // Run if this is the main module
320
+ if (import.meta.main) {
321
+ main().catch((err) => {
322
+ console.error("Server error:", err);
323
+ Deno.exit(1);
324
+ });
325
+ }
326
+
327
+ // Export for testing
328
+ export { createHandler, routeRequest, parseArgs };
@@ -0,0 +1,265 @@
1
+ /**
2
+ * API Server Tests
3
+ *
4
+ * Run with: deno test --allow-all api/server_test.ts
5
+ */
6
+
7
+ import {
8
+ assertEquals,
9
+ assertExists,
10
+ assertStringIncludes,
11
+ } from "https://deno.land/std@0.220.0/assert/mod.ts";
12
+
13
+ import { createHandler, routeRequest } from "./server.ts";
14
+ import { eventBus } from "./services/event-bus.ts";
15
+
16
+ // Test utilities
17
+ function createRequest(
18
+ method: string,
19
+ path: string,
20
+ body?: unknown,
21
+ headers: Record<string, string> = {}
22
+ ): Request {
23
+ const url = `http://localhost:8420${path}`;
24
+ const init: RequestInit = {
25
+ method,
26
+ headers: {
27
+ "Content-Type": "application/json",
28
+ ...headers,
29
+ },
30
+ };
31
+ if (body) {
32
+ init.body = JSON.stringify(body);
33
+ }
34
+ return new Request(url, init);
35
+ }
36
+
37
+ // ============================================================
38
+ // Health Endpoint Tests
39
+ // ============================================================
40
+
41
+ Deno.test("GET /health returns health status", async () => {
42
+ const handler = createHandler({ port: 8420, host: "localhost", cors: true, auth: false });
43
+ const req = createRequest("GET", "/health");
44
+ const res = await handler(req);
45
+
46
+ assertEquals(res.status, 200);
47
+ const data = await res.json();
48
+ assertExists(data.status);
49
+ assertExists(data.version);
50
+ assertExists(data.uptime);
51
+ assertExists(data.providers);
52
+ });
53
+
54
+ Deno.test("GET /health/live returns alive status", async () => {
55
+ const handler = createHandler({ port: 8420, host: "localhost", cors: true, auth: false });
56
+ const req = createRequest("GET", "/health/live");
57
+ const res = await handler(req);
58
+
59
+ assertEquals(res.status, 200);
60
+ const data = await res.json();
61
+ assertEquals(data.alive, true);
62
+ });
63
+
64
+ // ============================================================
65
+ // Session Endpoint Tests
66
+ // ============================================================
67
+
68
+ Deno.test("GET /api/sessions returns empty list initially", async () => {
69
+ const handler = createHandler({ port: 8420, host: "localhost", cors: true, auth: false });
70
+ const req = createRequest("GET", "/api/sessions");
71
+ const res = await handler(req);
72
+
73
+ assertEquals(res.status, 200);
74
+ const data = await res.json();
75
+ assertExists(data.sessions);
76
+ assertEquals(Array.isArray(data.sessions), true);
77
+ });
78
+
79
+ Deno.test("POST /api/sessions validates provider", async () => {
80
+ const handler = createHandler({ port: 8420, host: "localhost", cors: true, auth: false });
81
+ const req = createRequest("POST", "/api/sessions", { provider: "invalid" });
82
+ const res = await handler(req);
83
+
84
+ assertEquals(res.status, 422);
85
+ const data = await res.json();
86
+ assertStringIncludes(data.error, "Invalid provider");
87
+ });
88
+
89
+ Deno.test("GET /api/sessions/:id returns 404 for unknown session", async () => {
90
+ const handler = createHandler({ port: 8420, host: "localhost", cors: true, auth: false });
91
+ const req = createRequest("GET", "/api/sessions/unknown-id");
92
+ const res = await handler(req);
93
+
94
+ assertEquals(res.status, 404);
95
+ const data = await res.json();
96
+ assertEquals(data.code, "SESSION_NOT_FOUND");
97
+ });
98
+
99
+ // ============================================================
100
+ // Task Endpoint Tests
101
+ // ============================================================
102
+
103
+ Deno.test("GET /api/tasks returns empty list initially", async () => {
104
+ const handler = createHandler({ port: 8420, host: "localhost", cors: true, auth: false });
105
+ const req = createRequest("GET", "/api/tasks");
106
+ const res = await handler(req);
107
+
108
+ assertEquals(res.status, 200);
109
+ const data = await res.json();
110
+ assertExists(data.tasks);
111
+ assertEquals(Array.isArray(data.tasks), true);
112
+ });
113
+
114
+ Deno.test("GET /api/tasks/active returns active tasks", async () => {
115
+ const handler = createHandler({ port: 8420, host: "localhost", cors: true, auth: false });
116
+ const req = createRequest("GET", "/api/tasks/active");
117
+ const res = await handler(req);
118
+
119
+ assertEquals(res.status, 200);
120
+ const data = await res.json();
121
+ assertExists(data.tasks);
122
+ assertExists(data.count);
123
+ });
124
+
125
+ // ============================================================
126
+ // Event Endpoint Tests
127
+ // ============================================================
128
+
129
+ Deno.test("GET /api/events/stats returns event statistics", async () => {
130
+ const handler = createHandler({ port: 8420, host: "localhost", cors: true, auth: false });
131
+ const req = createRequest("GET", "/api/events/stats");
132
+ const res = await handler(req);
133
+
134
+ assertEquals(res.status, 200);
135
+ const data = await res.json();
136
+ assertExists(data.subscribers);
137
+ assertExists(data.historySize);
138
+ });
139
+
140
+ Deno.test("GET /api/events/history returns event history", async () => {
141
+ const handler = createHandler({ port: 8420, host: "localhost", cors: true, auth: false });
142
+ const req = createRequest("GET", "/api/events/history");
143
+ const res = await handler(req);
144
+
145
+ assertEquals(res.status, 200);
146
+ const data = await res.json();
147
+ assertExists(data.events);
148
+ assertEquals(Array.isArray(data.events), true);
149
+ });
150
+
151
+ // ============================================================
152
+ // Event Bus Tests
153
+ // ============================================================
154
+
155
+ Deno.test("EventBus publishes and receives events", () => {
156
+ const received: unknown[] = [];
157
+
158
+ const subId = eventBus.subscribe({}, (event) => {
159
+ received.push(event);
160
+ });
161
+
162
+ eventBus.publish("log:info", "test-session", { message: "test" });
163
+
164
+ assertEquals(received.length, 1);
165
+ assertEquals((received[0] as { type: string }).type, "log:info");
166
+
167
+ eventBus.unsubscribe(subId);
168
+ });
169
+
170
+ Deno.test("EventBus filters by session ID", () => {
171
+ const received: unknown[] = [];
172
+
173
+ const subId = eventBus.subscribe({ sessionId: "session-1" }, (event) => {
174
+ received.push(event);
175
+ });
176
+
177
+ eventBus.publish("log:info", "session-1", { message: "included" });
178
+ eventBus.publish("log:info", "session-2", { message: "excluded" });
179
+
180
+ assertEquals(received.length, 1);
181
+ assertEquals((received[0] as { sessionId: string }).sessionId, "session-1");
182
+
183
+ eventBus.unsubscribe(subId);
184
+ });
185
+
186
+ Deno.test("EventBus filters by event type", () => {
187
+ const received: unknown[] = [];
188
+
189
+ const subId = eventBus.subscribe({ types: ["log:error"] }, (event) => {
190
+ received.push(event);
191
+ });
192
+
193
+ eventBus.publish("log:info", "test", { message: "info" });
194
+ eventBus.publish("log:error", "test", { message: "error" });
195
+ eventBus.publish("log:warn", "test", { message: "warn" });
196
+
197
+ assertEquals(received.length, 1);
198
+ assertEquals((received[0] as { type: string }).type, "log:error");
199
+
200
+ eventBus.unsubscribe(subId);
201
+ });
202
+
203
+ // ============================================================
204
+ // CORS Tests
205
+ // ============================================================
206
+
207
+ Deno.test("OPTIONS request returns CORS headers", async () => {
208
+ const handler = createHandler({ port: 8420, host: "localhost", cors: true, auth: false });
209
+ const req = new Request("http://localhost:8420/api/sessions", {
210
+ method: "OPTIONS",
211
+ headers: {
212
+ Origin: "http://localhost:3000",
213
+ },
214
+ });
215
+ const res = await handler(req);
216
+
217
+ assertEquals(res.status, 204);
218
+ assertExists(res.headers.get("Access-Control-Allow-Origin"));
219
+ assertExists(res.headers.get("Access-Control-Allow-Methods"));
220
+ });
221
+
222
+ // ============================================================
223
+ // Error Handling Tests
224
+ // ============================================================
225
+
226
+ Deno.test("Unknown route returns 404", async () => {
227
+ const handler = createHandler({ port: 8420, host: "localhost", cors: true, auth: false });
228
+ const req = createRequest("GET", "/api/unknown");
229
+ const res = await handler(req);
230
+
231
+ assertEquals(res.status, 404);
232
+ const data = await res.json();
233
+ assertEquals(data.code, "NOT_FOUND");
234
+ });
235
+
236
+ Deno.test("Invalid JSON returns 400", async () => {
237
+ const handler = createHandler({ port: 8420, host: "localhost", cors: true, auth: false });
238
+ const req = new Request("http://localhost:8420/api/sessions", {
239
+ method: "POST",
240
+ headers: { "Content-Type": "application/json" },
241
+ body: "not valid json",
242
+ });
243
+ const res = await handler(req);
244
+
245
+ assertEquals(res.status, 400);
246
+ });
247
+
248
+ // ============================================================
249
+ // Status Endpoint Tests
250
+ // ============================================================
251
+
252
+ Deno.test("GET /api/status returns detailed status", async () => {
253
+ const handler = createHandler({ port: 8420, host: "localhost", cors: true, auth: false });
254
+ const req = createRequest("GET", "/api/status");
255
+ const res = await handler(req);
256
+
257
+ assertEquals(res.status, 200);
258
+ const data = await res.json();
259
+ assertExists(data.version);
260
+ assertExists(data.uptime);
261
+ assertExists(data.providers);
262
+ assertExists(data.sessions);
263
+ assertExists(data.events);
264
+ assertExists(data.system);
265
+ });