@syengup/friday-channel-next 0.0.1

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 (67) hide show
  1. package/README.md +35 -0
  2. package/index.ts +191 -0
  3. package/install.mjs +158 -0
  4. package/install.sh +118 -0
  5. package/openclaw.plugin.json +53 -0
  6. package/package.json +65 -0
  7. package/src/agent/abort-run.ts +10 -0
  8. package/src/agent/active-runs.ts +26 -0
  9. package/src/agent/dispatch-bridge.ts +18 -0
  10. package/src/agent/media-bridge.ts +23 -0
  11. package/src/agent-forward-runtime.ts +30 -0
  12. package/src/agent-run-context-bridge.ts +32 -0
  13. package/src/channel-actions.ts +129 -0
  14. package/src/channel.ts +284 -0
  15. package/src/collect-message-media-paths.ts +132 -0
  16. package/src/config.test.ts +33 -0
  17. package/src/config.ts +64 -0
  18. package/src/e2e/attachments-inbound.e2e.test.ts +43 -0
  19. package/src/e2e/attachments-outbound.e2e.test.ts +43 -0
  20. package/src/e2e/cancel-reconnect-errors.e2e.test.ts +56 -0
  21. package/src/e2e/connect-and-connected.e2e.test.ts +44 -0
  22. package/src/e2e/offline-replay.e2e.test.ts +43 -0
  23. package/src/e2e/send-text.e2e.test.ts +73 -0
  24. package/src/e2e/slash-commands.e2e.test.ts +33 -0
  25. package/src/e2e/status-cors-auth.e2e.test.ts +41 -0
  26. package/src/e2e/tool-lifecycle.e2e.test.ts +49 -0
  27. package/src/friday-inbound-stats.ts +10 -0
  28. package/src/friday-session.forward-agent.test.ts +270 -0
  29. package/src/friday-session.ts +327 -0
  30. package/src/host-config.ts +20 -0
  31. package/src/http/handlers/cancel.test.ts +70 -0
  32. package/src/http/handlers/cancel.ts +35 -0
  33. package/src/http/handlers/files-download.ts +239 -0
  34. package/src/http/handlers/files-upload.ts +166 -0
  35. package/src/http/handlers/files.ts +335 -0
  36. package/src/http/handlers/messages.test.ts +119 -0
  37. package/src/http/handlers/messages.ts +555 -0
  38. package/src/http/handlers/models-list.ts +126 -0
  39. package/src/http/handlers/sessions-delete.ts +59 -0
  40. package/src/http/handlers/sessions-settings.ts +90 -0
  41. package/src/http/handlers/sse.test.ts +71 -0
  42. package/src/http/handlers/sse.ts +84 -0
  43. package/src/http/handlers/status.test.ts +52 -0
  44. package/src/http/handlers/status.ts +33 -0
  45. package/src/http/middleware/auth.test.ts +46 -0
  46. package/src/http/middleware/auth.ts +31 -0
  47. package/src/http/middleware/body.test.ts +27 -0
  48. package/src/http/middleware/body.ts +28 -0
  49. package/src/http/middleware/cors.test.ts +40 -0
  50. package/src/http/middleware/cors.ts +12 -0
  51. package/src/http/server.ts +106 -0
  52. package/src/logging.ts +27 -0
  53. package/src/openclaw.d.ts +32 -0
  54. package/src/run-metadata.ts +180 -0
  55. package/src/runtime.ts +14 -0
  56. package/src/session/session-manager.ts +230 -0
  57. package/src/session-usage-snapshot.ts +80 -0
  58. package/src/sse/emitter.test.ts +85 -0
  59. package/src/sse/emitter.ts +249 -0
  60. package/src/sse/frame-format.test.ts +56 -0
  61. package/src/sse/offline-queue.test.ts +65 -0
  62. package/src/sse/offline-queue.ts +140 -0
  63. package/src/test-support/app-simulator.ts +243 -0
  64. package/src/test-support/mock-dispatch.ts +181 -0
  65. package/src/test-support/mock-runtime.ts +74 -0
  66. package/src/vendor/runtime-store.ts +99 -0
  67. package/tsconfig.json +17 -0
@@ -0,0 +1,85 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { EventEmitter } from "node:events";
6
+ import { sseEmitter } from "./emitter.js";
7
+ import { setOfflineQueueBaseDirForTest } from "./offline-queue.js";
8
+
9
+ class MockRes extends EventEmitter {
10
+ writes: string[] = [];
11
+ write(chunk: string): boolean {
12
+ this.writes.push(chunk);
13
+ return true;
14
+ }
15
+ end(): void {
16
+ // no-op
17
+ }
18
+ }
19
+
20
+ describe("sseEmitter", () => {
21
+ let tmp = "";
22
+
23
+ beforeEach(() => {
24
+ sseEmitter.resetForTest();
25
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "friday-sse-emit-"));
26
+ setOfflineQueueBaseDirForTest(tmp);
27
+ });
28
+
29
+ afterEach(() => {
30
+ setOfflineQueueBaseDirForTest(null);
31
+ try {
32
+ fs.rmSync(tmp, { recursive: true, force: true });
33
+ } catch {
34
+ // ignore
35
+ }
36
+ });
37
+
38
+ it("tracks run-to-device mapping", () => {
39
+ sseEmitter.trackDeviceForRun("device-a", "run-a");
40
+ expect(sseEmitter.getDeviceIdByRunId("run-a")).toBe("DEVICE-A");
41
+ expect(sseEmitter.getLastRunIdForDevice("device-a")).toBe("run-a");
42
+ expect(sseEmitter.hasTrackedDevices("run-a")).toBe(true);
43
+ sseEmitter.untrackRun("run-a");
44
+ expect(sseEmitter.hasTrackedDevices("run-a")).toBe(false);
45
+ });
46
+
47
+ it("uses per-device event id sequence", () => {
48
+ const a = new MockRes();
49
+ const b = new MockRes();
50
+ sseEmitter.addConnection("device-a-seq", a as never);
51
+ sseEmitter.addConnection("device-b-seq", b as never);
52
+
53
+ sseEmitter.broadcast({ type: "agent", data: { text: "1" } }, "device-a-seq", true);
54
+ sseEmitter.broadcast({ type: "agent", data: { text: "2" } }, "device-a-seq", true);
55
+ sseEmitter.broadcast({ type: "agent", data: { text: "x" } }, "device-b-seq", true);
56
+
57
+ const aw = a.writes.join("");
58
+ const bw = b.writes.join("");
59
+ expect(aw).toContain("id: 1");
60
+ expect(aw).toContain("id: 2");
61
+ expect(bw).toContain("id: 1");
62
+
63
+ sseEmitter.removeConnection("device-a-seq");
64
+ sseEmitter.removeConnection("device-b-seq");
65
+ });
66
+
67
+ it("replays only entries after last event id from disk", () => {
68
+ const c = new MockRes();
69
+ sseEmitter.addConnection("device-replay", c as never);
70
+ sseEmitter.setBacklogLimit(50);
71
+ sseEmitter.broadcast({ type: "agent", data: { text: "a" } }, "device-replay", true);
72
+ sseEmitter.broadcast({ type: "agent", data: { text: "b" } }, "device-replay", true);
73
+ sseEmitter.broadcast({ type: "agent", data: { text: "c" } }, "device-replay", true);
74
+
75
+ c.writes = [];
76
+ const replayed = sseEmitter.replayBacklog("device-replay", 1);
77
+ expect(replayed).toBe(2);
78
+ const body = c.writes.join("");
79
+ expect(body).toContain("id: 2");
80
+ expect(body).toContain("id: 3");
81
+ expect(body).not.toContain("text\":\"a\"");
82
+
83
+ sseEmitter.removeConnection("device-replay");
84
+ });
85
+ });
@@ -0,0 +1,249 @@
1
+ import type { ServerResponse } from "node:http";
2
+ import { createFridayNextLogger } from "../logging.js";
3
+ import { fridaySseOfflineQueue } from "./offline-queue.js";
4
+
5
+ const logger = createFridayNextLogger("sse", "info");
6
+
7
+ export type SseEventType = "connected" | "agent" | "deliver" | "tool-hook" | "outbound" | "ping";
8
+
9
+ export interface SseEvent {
10
+ type: SseEventType;
11
+ data: Record<string, unknown>;
12
+ }
13
+
14
+ type BacklogEntry = {
15
+ id: number;
16
+ event: SseEvent;
17
+ };
18
+
19
+ export class SseConnection {
20
+ readonly deviceId: string;
21
+ private readonly res: ServerResponse;
22
+ private closed = false;
23
+ private pending: string[] = [];
24
+ private flushTimer: ReturnType<typeof setTimeout> | null = null;
25
+ private waitingDrain = false;
26
+
27
+ constructor(deviceId: string, res: ServerResponse) {
28
+ this.deviceId = deviceId;
29
+ this.res = res;
30
+ this.res.on("drain", () => {
31
+ this.waitingDrain = false;
32
+ this.scheduleFlush();
33
+ });
34
+ this.res.on("error", () => this.close());
35
+ }
36
+
37
+ send(entry: BacklogEntry | SseEvent, flushNow?: boolean): void {
38
+ if (this.closed) return;
39
+ const normalized =
40
+ "id" in entry && "event" in entry ? entry : { id: Date.now(), event: entry as SseEvent };
41
+ const payload = JSON.stringify(normalized.event.data);
42
+ this.pending.push(
43
+ `id: ${normalized.id}\nevent: ${normalized.event.type}\ndata: ${payload}\n\n`,
44
+ );
45
+ if (flushNow) {
46
+ if (this.flushTimer) clearTimeout(this.flushTimer);
47
+ this.flushTimer = null;
48
+ this.flush();
49
+ return;
50
+ }
51
+ this.scheduleFlush();
52
+ }
53
+
54
+ sendRaw(line: string): void {
55
+ if (this.closed) return;
56
+ const ok = this.res.write(line);
57
+ if (ok === false) this.waitingDrain = true;
58
+ }
59
+
60
+ private scheduleFlush(): void {
61
+ if (this.waitingDrain || this.flushTimer) return;
62
+ this.flushTimer = setTimeout(() => {
63
+ this.flushTimer = null;
64
+ this.flush();
65
+ }, 16);
66
+ }
67
+
68
+ private flush(): void {
69
+ if (this.closed || this.waitingDrain || this.pending.length === 0) return;
70
+ const data = this.pending.join("");
71
+ this.pending = [];
72
+ const ok = this.res.write(data);
73
+ if (ok === false) this.waitingDrain = true;
74
+ }
75
+
76
+ close(): void {
77
+ if (this.closed) return;
78
+ this.closed = true;
79
+ if (this.flushTimer) clearTimeout(this.flushTimer);
80
+ this.flushTimer = null;
81
+ this.pending = [];
82
+ try {
83
+ this.res.end();
84
+ } catch {
85
+ // ignore
86
+ }
87
+ }
88
+
89
+ get isClosed(): boolean {
90
+ return this.closed;
91
+ }
92
+ }
93
+
94
+ class SseEmitterRegistry {
95
+ private connections = new Map<string, SseConnection>();
96
+ private runEmitter = new Map<string, Set<string>>();
97
+ private lastRunIdByDevice = new Map<string, string>();
98
+ private eventSeqByDevice = new Map<string, number>();
99
+ private backlogLimit = 200;
100
+
101
+ getConnectionCount(): number {
102
+ return this.connections.size;
103
+ }
104
+
105
+ setBacklogLimit(limit: number): void {
106
+ this.backlogLimit = Math.max(0, Math.floor(limit));
107
+ }
108
+
109
+ getBacklogLimit(): number {
110
+ return this.backlogLimit;
111
+ }
112
+
113
+ /** Last persisted / assigned SSE id for device (for `connected.lastSeq`). */
114
+ latestSeqForDevice(deviceId: string): number {
115
+ const key = deviceId.trim().toUpperCase();
116
+ const disk = fridaySseOfflineQueue.latestId(key);
117
+ const mem = this.eventSeqByDevice.get(key) ?? 0;
118
+ return Math.max(disk, mem);
119
+ }
120
+
121
+ addConnection(deviceId: string, res: ServerResponse): SseConnection {
122
+ const normalized = deviceId.trim().toUpperCase();
123
+ const existing = this.connections.get(normalized);
124
+ if (existing && !existing.isClosed) {
125
+ existing.close();
126
+ for (const set of this.runEmitter.values()) {
127
+ set.delete(normalized);
128
+ }
129
+ }
130
+ const conn = new SseConnection(normalized, res);
131
+ this.connections.set(normalized, conn);
132
+ logger.info(`connect ${normalized} total=${this.connections.size}`);
133
+ return conn;
134
+ }
135
+
136
+ /**
137
+ * @param expectedConn When provided, only removes if this connection is still the active one
138
+ * (avoids stale `req.close` after a reconnect replaced the map entry).
139
+ */
140
+ removeConnection(deviceId: string, expectedConn?: SseConnection): void {
141
+ const normalized = deviceId.trim().toUpperCase();
142
+ const current = this.connections.get(normalized);
143
+ if (expectedConn !== undefined && current !== expectedConn) {
144
+ return;
145
+ }
146
+ current?.close();
147
+ this.connections.delete(normalized);
148
+ for (const set of this.runEmitter.values()) set.delete(normalized);
149
+ logger.info(`disconnect ${normalized} total=${this.connections.size}`);
150
+ }
151
+
152
+ getConnection(deviceId: string): SseConnection | undefined {
153
+ return this.connections.get(deviceId.trim().toUpperCase());
154
+ }
155
+
156
+ private nextEntry(deviceId: string, event: SseEvent): BacklogEntry {
157
+ const key = deviceId.trim().toUpperCase();
158
+ const diskMax = fridaySseOfflineQueue.latestId(key);
159
+ const memMax = this.eventSeqByDevice.get(key) ?? 0;
160
+ const last = Math.max(memMax, diskMax);
161
+ const id = last + 1;
162
+ this.eventSeqByDevice.set(key, id);
163
+ fridaySseOfflineQueue.append(key, id, event.type, event.data, this.backlogLimit);
164
+ return { id, event };
165
+ }
166
+
167
+ replayBacklog(deviceId: string, afterEventId: number): number {
168
+ const key = deviceId.trim().toUpperCase();
169
+ const conn = this.connections.get(key);
170
+ if (!conn) return 0;
171
+ const entries = fridaySseOfflineQueue.readAfter(key, afterEventId);
172
+ let count = 0;
173
+ for (const e of entries) {
174
+ conn.send({ id: e.id, event: { type: e.event as SseEventType, data: e.data } }, true);
175
+ count += 1;
176
+ }
177
+ return count;
178
+ }
179
+
180
+ broadcast(event: SseEvent, deviceId?: string, flushNow?: boolean): void {
181
+ if (deviceId) {
182
+ const key = deviceId.trim().toUpperCase();
183
+ const entry = this.nextEntry(key, event);
184
+ this.connections.get(key)?.send(entry, flushNow);
185
+ return;
186
+ }
187
+ for (const conn of this.connections.values()) {
188
+ const entry = this.nextEntry(conn.deviceId, event);
189
+ conn.send(entry, flushNow);
190
+ }
191
+ }
192
+
193
+ trackDeviceForRun(deviceId: string, runId: string): void {
194
+ const key = deviceId.trim().toUpperCase();
195
+ const set = this.runEmitter.get(runId) ?? new Set<string>();
196
+ set.add(key);
197
+ this.runEmitter.set(runId, set);
198
+ this.lastRunIdByDevice.set(key, runId);
199
+ }
200
+
201
+ untrackRun(runId: string): void {
202
+ this.runEmitter.delete(runId);
203
+ }
204
+
205
+ hasTrackedDevices(runId: string): boolean {
206
+ return (this.runEmitter.get(runId)?.size ?? 0) > 0;
207
+ }
208
+
209
+ getDeviceIdByRunId(runId: string): string | null {
210
+ const first = this.runEmitter.get(runId)?.values().next().value;
211
+ return typeof first === "string" ? first : null;
212
+ }
213
+
214
+ getSoleConnectedDeviceId(): string | null {
215
+ if (this.connections.size !== 1) return null;
216
+ return this.connections.keys().next().value ?? null;
217
+ }
218
+
219
+ getLastRunIdForDevice(deviceId: string): string | null {
220
+ return this.lastRunIdByDevice.get(deviceId.trim().toUpperCase()) ?? null;
221
+ }
222
+
223
+ broadcastToRun(runId: string, event: SseEvent, flushNow?: boolean): void {
224
+ const direct = typeof event.data.deviceId === "string" ? event.data.deviceId : "";
225
+ if (direct.trim()) {
226
+ this.broadcast(event, direct, flushNow);
227
+ return;
228
+ }
229
+ const set = this.runEmitter.get(runId);
230
+ if (!set || set.size === 0) return;
231
+ for (const deviceId of set) this.broadcast(event, deviceId, flushNow);
232
+ }
233
+
234
+ broadcastToolEvent(deviceId: string, runId: string, event: SseEvent, flushNow?: boolean): void {
235
+ this.trackDeviceForRun(deviceId, runId);
236
+ this.broadcastToRun(runId, event, flushNow ?? true);
237
+ }
238
+
239
+ /** Vitest / e2e: drop connections and in-memory seq maps (does not delete disk queue files). */
240
+ resetForTest(): void {
241
+ for (const c of this.connections.values()) c.close();
242
+ this.connections.clear();
243
+ this.runEmitter.clear();
244
+ this.lastRunIdByDevice.clear();
245
+ this.eventSeqByDevice.clear();
246
+ }
247
+ }
248
+
249
+ export const sseEmitter = new SseEmitterRegistry();
@@ -0,0 +1,56 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { EventEmitter } from "node:events";
6
+ import { sseEmitter } from "./emitter.js";
7
+ import { setOfflineQueueBaseDirForTest } from "./offline-queue.js";
8
+
9
+ class MockRes extends EventEmitter {
10
+ writes: string[] = [];
11
+ write(chunk: string): boolean {
12
+ this.writes.push(chunk);
13
+ return true;
14
+ }
15
+ end(): void {
16
+ // no-op
17
+ }
18
+ }
19
+
20
+ describe("sse frame format", () => {
21
+ let tmp = "";
22
+
23
+ beforeEach(() => {
24
+ sseEmitter.resetForTest();
25
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "friday-frame-"));
26
+ setOfflineQueueBaseDirForTest(tmp);
27
+ });
28
+
29
+ afterEach(() => {
30
+ setOfflineQueueBaseDirForTest(null);
31
+ fs.rmSync(tmp, { recursive: true, force: true });
32
+ });
33
+
34
+ it("emits id/event/data with raw JSON payload", () => {
35
+ const res = new MockRes();
36
+ sseEmitter.addConnection("frame-device", res as never);
37
+
38
+ sseEmitter.broadcast(
39
+ {
40
+ type: "agent",
41
+ data: { runId: "r1", stream: "assistant", text: "hello" },
42
+ },
43
+ "frame-device",
44
+ true,
45
+ );
46
+
47
+ const frame = res.writes.join("");
48
+ expect(frame).toContain("id: 1\n");
49
+ expect(frame).toContain("event: agent\n");
50
+ expect(frame).toContain('"runId":"r1"');
51
+ expect(frame).toContain('"stream":"assistant"');
52
+ expect(frame).toContain('"text":"hello"');
53
+
54
+ sseEmitter.removeConnection("frame-device");
55
+ });
56
+ });
@@ -0,0 +1,65 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { FridaySseOfflineQueue, setOfflineQueueBaseDirForTest } from "./offline-queue.js";
6
+
7
+ describe("FridaySseOfflineQueue", () => {
8
+ let tmp = "";
9
+
10
+ afterEach(() => {
11
+ setOfflineQueueBaseDirForTest(null);
12
+ if (tmp) {
13
+ try {
14
+ fs.rmSync(tmp, { recursive: true, force: true });
15
+ } catch {
16
+ // ignore
17
+ }
18
+ tmp = "";
19
+ }
20
+ });
21
+
22
+ it("append / readAfter / latestId", () => {
23
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "friday-q-"));
24
+ setOfflineQueueBaseDirForTest(tmp);
25
+ const q = new FridaySseOfflineQueue(tmp);
26
+ expect(q.latestId("dev-a")).toBe(0);
27
+ q.append("dev-a", 1, "agent", { x: 1 }, 100);
28
+ q.append("dev-a", 2, "deliver", { y: 2 }, 100);
29
+ expect(q.latestId("dev-a")).toBe(2);
30
+ expect(q.readAfter("dev-a", 0).map((e) => e.id)).toEqual([1, 2]);
31
+ expect(q.readAfter("dev-a", 1).map((e) => e.id)).toEqual([2]);
32
+ });
33
+
34
+ it("does not persist connected", () => {
35
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "friday-q-"));
36
+ setOfflineQueueBaseDirForTest(tmp);
37
+ const q = new FridaySseOfflineQueue(tmp);
38
+ q.append("dev-b", 1, "connected", { ok: true }, 100);
39
+ expect(q.readAfter("dev-b", 0)).toEqual([]);
40
+ expect(q.latestId("dev-b")).toBe(0);
41
+ });
42
+
43
+ it("truncateKeepLastN drops oldest", () => {
44
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "friday-q-"));
45
+ setOfflineQueueBaseDirForTest(tmp);
46
+ const q = new FridaySseOfflineQueue(tmp);
47
+ for (let i = 1; i <= 5; i++) {
48
+ q.append("dev-c", i, "agent", { i }, 0);
49
+ }
50
+ q.truncateKeepLastN("dev-c", 2);
51
+ const rest = q.readAfter("dev-c", 0);
52
+ expect(rest.map((e) => e.id)).toEqual([4, 5]);
53
+ });
54
+
55
+ it("append with backlogLimit truncates", () => {
56
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "friday-q-"));
57
+ setOfflineQueueBaseDirForTest(tmp);
58
+ const q = new FridaySseOfflineQueue(tmp);
59
+ for (let i = 1; i <= 4; i++) {
60
+ q.append("dev-d", i, "agent", { i }, 2);
61
+ }
62
+ const rest = q.readAfter("dev-d", 0);
63
+ expect(rest.map((e) => e.id)).toEqual([3, 4]);
64
+ });
65
+ });
@@ -0,0 +1,140 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { resolveFridayNextConfig } from "../config.js";
5
+ import { getHostOpenClawConfigSnapshot } from "../host-config.js";
6
+ import { getFridayNextRuntime } from "../runtime.js";
7
+
8
+ export type PersistedSseEntry = {
9
+ id: number;
10
+ event: string;
11
+ data: Record<string, unknown>;
12
+ };
13
+
14
+ /** Test-only override for queue base directory. */
15
+ let testQueueBaseDir: string | null = null;
16
+
17
+ export function setOfflineQueueBaseDirForTest(dir: string | null): void {
18
+ testQueueBaseDir = dir;
19
+ }
20
+
21
+ export function resolveFridayNextEventsQueueDir(): string {
22
+ if (testQueueBaseDir) return testQueueBaseDir;
23
+ try {
24
+ const cfg = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(getFridayNextRuntime().config));
25
+ return path.join(path.dirname(cfg.historyDir), "events-queue");
26
+ } catch {
27
+ return path.join(os.homedir(), ".openclaw", "friday-next", "events-queue");
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Per-device JSONL persistence for SSE replay.
33
+ * `overrideBaseDir` is for tests; production uses `resolveFridayNextEventsQueueDir()`.
34
+ */
35
+ export class FridaySseOfflineQueue {
36
+ constructor(private readonly overrideBaseDir: string | null = null) {}
37
+
38
+ private baseDir(): string {
39
+ if (this.overrideBaseDir) return this.overrideBaseDir;
40
+ return resolveFridayNextEventsQueueDir();
41
+ }
42
+
43
+ private devicePath(deviceId: string): string {
44
+ const key = deviceId.trim().toUpperCase();
45
+ return path.join(this.baseDir(), `${key}.jsonl`);
46
+ }
47
+
48
+ private ensureDir(): void {
49
+ fs.mkdirSync(this.baseDir(), { recursive: true });
50
+ }
51
+
52
+ /** Highest id in file (full scan; ok for bounded backlog). */
53
+ scanMaxId(deviceId: string): number {
54
+ const file = this.devicePath(deviceId);
55
+ if (!fs.existsSync(file)) return 0;
56
+ let max = 0;
57
+ const content = fs.readFileSync(file, "utf8");
58
+ for (const line of content.split("\n")) {
59
+ if (!line.trim()) continue;
60
+ try {
61
+ const o = JSON.parse(line) as { id?: number };
62
+ if (typeof o.id === "number" && o.id > max) max = o.id;
63
+ } catch {
64
+ /* skip corrupt line */
65
+ }
66
+ }
67
+ return max;
68
+ }
69
+
70
+ latestId(deviceId: string): number {
71
+ return this.scanMaxId(deviceId.trim().toUpperCase());
72
+ }
73
+
74
+ append(deviceId: string, id: number, event: string, data: Record<string, unknown>, backlogLimit: number): void {
75
+ if (event === "connected") return;
76
+ this.ensureDir();
77
+ const file = this.devicePath(deviceId);
78
+ const line = JSON.stringify({ id, event, data } satisfies PersistedSseEntry) + "\n";
79
+ fs.appendFileSync(file, line, "utf8");
80
+ if (backlogLimit > 0) {
81
+ this.truncateKeepLastN(deviceId, backlogLimit);
82
+ }
83
+ }
84
+
85
+ readAfter(deviceId: string, afterId: number): PersistedSseEntry[] {
86
+ const file = this.devicePath(deviceId);
87
+ if (!fs.existsSync(file)) return [];
88
+ const out: PersistedSseEntry[] = [];
89
+ const content = fs.readFileSync(file, "utf8");
90
+ for (const line of content.split("\n")) {
91
+ if (!line.trim()) continue;
92
+ try {
93
+ const o = JSON.parse(line) as PersistedSseEntry;
94
+ if (
95
+ typeof o.id === "number" &&
96
+ o.id > afterId &&
97
+ typeof o.event === "string" &&
98
+ o.data &&
99
+ typeof o.data === "object" &&
100
+ !Array.isArray(o.data)
101
+ ) {
102
+ out.push(o);
103
+ }
104
+ } catch {
105
+ /* skip */
106
+ }
107
+ }
108
+ out.sort((a, b) => a.id - b.id);
109
+ return out;
110
+ }
111
+
112
+ truncateKeepLastN(deviceId: string, keep: number): void {
113
+ if (keep <= 0) return;
114
+ const file = this.devicePath(deviceId);
115
+ if (!fs.existsSync(file)) return;
116
+ const all: PersistedSseEntry[] = [];
117
+ const content = fs.readFileSync(file, "utf8");
118
+ for (const line of content.split("\n")) {
119
+ if (!line.trim()) continue;
120
+ try {
121
+ const o = JSON.parse(line) as PersistedSseEntry;
122
+ if (typeof o.id === "number" && typeof o.event === "string" && o.data && typeof o.data === "object") {
123
+ all.push(o);
124
+ }
125
+ } catch {
126
+ /* skip */
127
+ }
128
+ }
129
+ if (all.length <= keep) return;
130
+ const slice = all.slice(-keep);
131
+ fs.writeFileSync(
132
+ file,
133
+ slice.map((e) => JSON.stringify(e) + "\n").join(""),
134
+ "utf8",
135
+ );
136
+ }
137
+ }
138
+
139
+ /** Shared queue: base directory follows `setOfflineQueueBaseDirForTest` / config. */
140
+ export const fridaySseOfflineQueue = new FridaySseOfflineQueue(null);