@blackbelt-technology/pi-agent-dashboard 0.4.4 → 0.4.5
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/AGENTS.md +38 -33
- package/README.md +1 -0
- package/docs/architecture.md +162 -4
- package/package.json +4 -4
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/connection-suppress-auto-start.test.ts +97 -0
- package/packages/extension/src/__tests__/session-sync.test.ts +81 -1
- package/packages/extension/src/bridge-context.ts +10 -0
- package/packages/extension/src/bridge.ts +22 -0
- package/packages/extension/src/connection.ts +29 -0
- package/packages/extension/src/server-auto-start.ts +16 -0
- package/packages/extension/src/session-sync.ts +14 -0
- package/packages/server/package.json +4 -4
- package/packages/server/src/__tests__/cli-restart.test.ts +78 -0
- package/packages/server/src/__tests__/config-api.test.ts +9 -0
- package/packages/server/src/__tests__/is-activity-event.test.ts +78 -0
- package/packages/server/src/__tests__/is-unread-trigger.test.ts +164 -0
- package/packages/server/src/__tests__/last-activity-broadcast.test.ts +190 -0
- package/packages/server/src/__tests__/reattach-placement.test.ts +231 -0
- package/packages/server/src/__tests__/restart-helper.test.ts +25 -0
- package/packages/server/src/__tests__/session-order-reboot.test.ts +117 -0
- package/packages/server/src/__tests__/session-scanner.test.ts +31 -0
- package/packages/server/src/__tests__/system-routes-restart.test.ts +128 -0
- package/packages/server/src/__tests__/unread-persistence.test.ts +55 -0
- package/packages/server/src/__tests__/unread-trigger-wiring.test.ts +210 -0
- package/packages/server/src/__tests__/viewed-session-tracker.test.ts +93 -0
- package/packages/server/src/browser-gateway.ts +36 -0
- package/packages/server/src/cli.ts +70 -2
- package/packages/server/src/event-status-extraction.ts +98 -1
- package/packages/server/src/event-wiring.ts +70 -1
- package/packages/server/src/memory-session-manager.ts +34 -3
- package/packages/server/src/pi-gateway.ts +4 -0
- package/packages/server/src/reattach-placement.ts +98 -0
- package/packages/server/src/restart-helper.ts +41 -2
- package/packages/server/src/routes/system-routes.ts +25 -1
- package/packages/server/src/server.ts +55 -3
- package/packages/server/src/session-scanner.ts +19 -0
- package/packages/server/src/viewed-session-tracker.ts +78 -0
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/config.test.ts +59 -0
- package/packages/shared/src/__tests__/mdns-discovery.test.ts +48 -1
- package/packages/shared/src/__tests__/no-bash-on-windows.test.ts +304 -0
- package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +1 -1
- package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +107 -0
- package/packages/shared/src/__tests__/protocol.test.ts +11 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +92 -0
- package/packages/shared/src/browser-protocol.ts +25 -0
- package/packages/shared/src/config.ts +41 -0
- package/packages/shared/src/mdns-discovery.ts +32 -1
- package/packages/shared/src/platform/node-spawn.ts +30 -0
- package/packages/shared/src/protocol.ts +30 -1
- package/packages/shared/src/session-meta.ts +6 -0
- package/packages/shared/src/types.ts +19 -0
|
@@ -16,7 +16,11 @@
|
|
|
16
16
|
import { describe, it, expect, beforeEach } from "vitest";
|
|
17
17
|
import { createPendingResumeIntentRegistry } from "../pending-resume-intent-registry.js";
|
|
18
18
|
import { createSessionOrderManager } from "../session-order-manager.js";
|
|
19
|
+
import { applyReattachPolicy } from "../reattach-placement.js";
|
|
20
|
+
import { createMemorySessionManager } from "../memory-session-manager.js";
|
|
21
|
+
import type { ReattachPlacement } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
19
22
|
import type { PreferencesStore } from "../preferences-store.js";
|
|
23
|
+
import type { BrowserGateway } from "../browser-gateway.js";
|
|
20
24
|
|
|
21
25
|
// In-memory PreferencesStore mock matching the slice of the interface
|
|
22
26
|
// `sessionOrderManager` consumes.
|
|
@@ -207,6 +211,119 @@ describe("ended\u2192alive sessionOrder gate", () => {
|
|
|
207
211
|
expect(sessionOrderManager.getOrder(cwd)).toEqual(["X", "A"]);
|
|
208
212
|
});
|
|
209
213
|
|
|
214
|
+
// ── reattach-move-to-front ──
|
|
215
|
+
|
|
216
|
+
it("reattach with policy 'always' on persisted-active session lands at index 0 + broadcasts", () => {
|
|
217
|
+
// Repro: 6 alive sessions in cwd, none ended. The user's was at
|
|
218
|
+
// index 5. Dashboard restarts; bridge reconnects with
|
|
219
|
+
// registerReason: "reattach" for that session id.
|
|
220
|
+
sessionOrderManager.reorder(cwd, ["S0", "S1", "S2", "S3", "S4", "S5"]);
|
|
221
|
+
|
|
222
|
+
// No ended→alive transition fires (wasEnded=false, isEnded=false)
|
|
223
|
+
// for these reattaches; we exercise the new branch in server.ts
|
|
224
|
+
// directly via the helper.
|
|
225
|
+
const sm = createMemorySessionManager();
|
|
226
|
+
sm.register({ id: "S5", cwd, source: "tui" });
|
|
227
|
+
|
|
228
|
+
const broadcasts: any[] = [];
|
|
229
|
+
const gateway = { broadcastToAll: (m: any) => broadcasts.push(m) } as unknown as BrowserGateway;
|
|
230
|
+
|
|
231
|
+
const action = applyReattachPolicy("S5", cwd, "always", {
|
|
232
|
+
sessionManager: sm,
|
|
233
|
+
sessionOrderManager,
|
|
234
|
+
browserGateway: gateway,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
expect(action).toBe("moveToFront");
|
|
238
|
+
expect(sessionOrderManager.getOrder(cwd)).toEqual(["S5", "S0", "S1", "S2", "S3", "S4"]);
|
|
239
|
+
expect(broadcasts).toHaveLength(1);
|
|
240
|
+
expect(broadcasts[0].sessionIds[0]).toBe("S5");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("reattach with policy 'preserve' is a no-op (legacy behavior)", () => {
|
|
244
|
+
sessionOrderManager.reorder(cwd, ["S0", "S1", "S2"]);
|
|
245
|
+
const sm = createMemorySessionManager();
|
|
246
|
+
sm.register({ id: "S2", cwd, source: "tui" });
|
|
247
|
+
|
|
248
|
+
const broadcasts: any[] = [];
|
|
249
|
+
const gateway = { broadcastToAll: (m: any) => broadcasts.push(m) } as unknown as BrowserGateway;
|
|
250
|
+
|
|
251
|
+
const action = applyReattachPolicy("S2", cwd, "preserve", {
|
|
252
|
+
sessionManager: sm,
|
|
253
|
+
sessionOrderManager,
|
|
254
|
+
browserGateway: gateway,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(action).toBe("preserve");
|
|
258
|
+
expect(sessionOrderManager.getOrder(cwd)).toEqual(["S0", "S1", "S2"]);
|
|
259
|
+
expect(broadcasts).toEqual([]);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("legacy bridge (no registerReason) does NOT trigger reattach policy", () => {
|
|
263
|
+
// Spec scenario: "Legacy bridge reattach without registerReason
|
|
264
|
+
// preserves layout". Pre-this-change bridges omit the field; the
|
|
265
|
+
// server treats omission as `"spawn"` and skips the policy entirely.
|
|
266
|
+
sessionOrderManager.reorder(cwd, ["S0", "S1", "S2"]);
|
|
267
|
+
|
|
268
|
+
// Simulate the alive→alive branch's gate condition with a missing
|
|
269
|
+
// registerReason (i.e. ctx?.registerReason === undefined). Per the
|
|
270
|
+
// server.ts onChange code, the branch should not fire.
|
|
271
|
+
const ctx = { registerReason: undefined } as { registerReason?: "spawn" | "reattach" };
|
|
272
|
+
const shouldFire = ctx.registerReason === "reattach";
|
|
273
|
+
expect(shouldFire).toBe(false);
|
|
274
|
+
|
|
275
|
+
// Order remains untouched.
|
|
276
|
+
expect(sessionOrderManager.getOrder(cwd)).toEqual(["S0", "S1", "S2"]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("registry intent wins over registerReason: reattach (defensive guarantee)", () => {
|
|
280
|
+
// Spec scenario: "Registry intent wins over reattach". A registry
|
|
281
|
+
// intent of "front" or "keep" must override any registerReason:
|
|
282
|
+
// "reattach" so user actions are never clobbered by the policy.
|
|
283
|
+
sessionOrderManager.reorder(cwd, ["S0", "S1", "S2"]);
|
|
284
|
+
const sm = createMemorySessionManager();
|
|
285
|
+
sm.register({ id: "S2", cwd, source: "tui" });
|
|
286
|
+
|
|
287
|
+
// Replicate the server.ts alive→alive branch logic verbatim:
|
|
288
|
+
// consume registry first, defer to it if non-null.
|
|
289
|
+
pendingResumeIntents.record("S2", "front");
|
|
290
|
+
const intent = pendingResumeIntents.consume("S2");
|
|
291
|
+
expect(intent).toBe("front");
|
|
292
|
+
|
|
293
|
+
// With intent === "front", the registry path moves to front;
|
|
294
|
+
// the reattach policy is NOT invoked.
|
|
295
|
+
sessionOrderManager.moveToFront(cwd, "S2");
|
|
296
|
+
expect(sessionOrderManager.getOrder(cwd)).toEqual(["S2", "S0", "S1"]);
|
|
297
|
+
|
|
298
|
+
// Verify policy.applyReattachPolicy would also have moved-to-front;
|
|
299
|
+
// the test guarantees registry-path wins by being executed first.
|
|
300
|
+
// (If both paths fired, S2 would still be at front, but only one
|
|
301
|
+
// sessions_reordered broadcast should be emitted in production.)
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("reattach respects 'streaming-only' policy: only moves streaming sessions", () => {
|
|
305
|
+
sessionOrderManager.reorder(cwd, ["S0", "S1", "S2"]);
|
|
306
|
+
const sm = createMemorySessionManager();
|
|
307
|
+
sm.register({ id: "S1", cwd, source: "tui" });
|
|
308
|
+
sm.register({ id: "S2", cwd, source: "tui" });
|
|
309
|
+
sm.update("S2", { status: "streaming" });
|
|
310
|
+
|
|
311
|
+
const policies: ReattachPlacement[] = ["streaming-only"];
|
|
312
|
+
for (const p of policies) {
|
|
313
|
+
// S1 is active, not streaming → preserve
|
|
314
|
+
const broadcastsA: any[] = [];
|
|
315
|
+
const gA = { broadcastToAll: (m: any) => broadcastsA.push(m) } as unknown as BrowserGateway;
|
|
316
|
+
expect(applyReattachPolicy("S1", cwd, p, { sessionManager: sm, sessionOrderManager, browserGateway: gA })).toBe("preserve");
|
|
317
|
+
expect(broadcastsA).toEqual([]);
|
|
318
|
+
|
|
319
|
+
// S2 is streaming → moveToFront
|
|
320
|
+
const broadcastsB: any[] = [];
|
|
321
|
+
const gB = { broadcastToAll: (m: any) => broadcastsB.push(m) } as unknown as BrowserGateway;
|
|
322
|
+
expect(applyReattachPolicy("S2", cwd, p, { sessionManager: sm, sessionOrderManager, browserGateway: gB })).toBe("moveToFront");
|
|
323
|
+
expect(broadcastsB).toHaveLength(1);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
210
327
|
it("end → resume → end → resume cycle always lands id at index 0", () => {
|
|
211
328
|
// Regression for `top-of-tier-on-status-change`: pre-fix the
|
|
212
329
|
// ended→alive branch used insert-if-absent, so on the second resume
|
|
@@ -108,6 +108,37 @@ describe("session-scanner", () => {
|
|
|
108
108
|
expect(meta.cachedAt).toBeGreaterThan(0);
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
it("should seed lastActivityAt from events.jsonl mtime (cold-start, cached meta path)", () => {
|
|
112
|
+
const dir = createSessionDir("--test-cwd--");
|
|
113
|
+
const sf = createJsonl(dir, "2026-03-30T21-39-43-034Z_seed-id.jsonl", { id: "seed-id", cwd: "/seed" });
|
|
114
|
+
writeSessionMeta(sf, {
|
|
115
|
+
cwd: "/seed",
|
|
116
|
+
status: "ended",
|
|
117
|
+
startedAt: 1000,
|
|
118
|
+
cachedAt: Date.now() + 10_000, // fresh cache so we hit the cached-meta arm
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Force a known mtime on the .jsonl
|
|
122
|
+
const knownMtime = new Date("2026-04-15T10:00:00.000Z");
|
|
123
|
+
fs.utimesSync(sf, knownMtime, knownMtime);
|
|
124
|
+
|
|
125
|
+
const result = scanAllSessions(tmpDir);
|
|
126
|
+
expect(result.sessions).toHaveLength(1);
|
|
127
|
+
expect(result.sessions[0].lastActivityAt).toBe(knownMtime.getTime());
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("should seed lastActivityAt from events.jsonl mtime (fallback parse path)", () => {
|
|
131
|
+
const dir = createSessionDir("--test-cwd--");
|
|
132
|
+
const sf = createJsonl(dir, "2026-03-30T21-39-43-034Z_fallback-seed.jsonl", { id: "fallback-seed", cwd: "/seed2" });
|
|
133
|
+
// No .meta.json — forces the fallback-parse arm.
|
|
134
|
+
const knownMtime = new Date("2026-04-16T11:30:00.000Z");
|
|
135
|
+
fs.utimesSync(sf, knownMtime, knownMtime);
|
|
136
|
+
|
|
137
|
+
const result = scanAllSessions(tmpDir);
|
|
138
|
+
expect(result.sessions).toHaveLength(1);
|
|
139
|
+
expect(result.sessions[0].lastActivityAt).toBe(knownMtime.getTime());
|
|
140
|
+
});
|
|
141
|
+
|
|
111
142
|
it("should ignore orphaned .meta.json without .jsonl", () => {
|
|
112
143
|
const dir = createSessionDir("--test-cwd--");
|
|
113
144
|
// Write .meta.json without a corresponding .jsonl
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `/api/restart` and `/api/shutdown` `server_restarting` broadcast.
|
|
3
|
+
* See change: fix-restart-bridge-auto-start-race.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
6
|
+
import Fastify, { type FastifyInstance } from "fastify";
|
|
7
|
+
import { registerSystemRoutes } from "../routes/system-routes.js";
|
|
8
|
+
import type { PiGateway } from "../pi-gateway.js";
|
|
9
|
+
import type { ServerToExtensionMessage } from "@blackbelt-technology/pi-dashboard-shared/protocol.js";
|
|
10
|
+
|
|
11
|
+
function noGuard() {
|
|
12
|
+
return async () => { /* allow all */ };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function makeNoopDeps() {
|
|
16
|
+
return {
|
|
17
|
+
sessionManager: {} as never,
|
|
18
|
+
preferencesStore: { flush: () => {} } as never,
|
|
19
|
+
metaPersistence: { flushAll: () => {} } as never,
|
|
20
|
+
config: { port: 8000, piPort: 9999, dev: false } as never,
|
|
21
|
+
networkGuard: noGuard(),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeFakeGateway(): { gateway: PiGateway; broadcasts: ServerToExtensionMessage[] } {
|
|
26
|
+
const broadcasts: ServerToExtensionMessage[] = [];
|
|
27
|
+
const gateway: PiGateway = {
|
|
28
|
+
broadcast(msg) { broadcasts.push(msg); },
|
|
29
|
+
sendToSession() { return false; },
|
|
30
|
+
isSessionConnected() { return false; },
|
|
31
|
+
connectionCount() { return 0; },
|
|
32
|
+
onSessionRegistered() { /* no-op */ },
|
|
33
|
+
onConnectionClosed() { /* no-op */ },
|
|
34
|
+
close() { /* no-op */ },
|
|
35
|
+
} as unknown as PiGateway;
|
|
36
|
+
return { gateway, broadcasts };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("POST /api/restart broadcasts server_restarting", () => {
|
|
40
|
+
let fastify: FastifyInstance;
|
|
41
|
+
let exitSpy: ReturnType<typeof vi.spyOn>;
|
|
42
|
+
let broadcasts: ServerToExtensionMessage[];
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
fastify = Fastify();
|
|
46
|
+
const fake = makeFakeGateway();
|
|
47
|
+
broadcasts = fake.broadcasts;
|
|
48
|
+
// process.exit is deferred via setTimeout(...,200); silence it for the test
|
|
49
|
+
exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => undefined as never));
|
|
50
|
+
registerSystemRoutes(fastify, { ...makeNoopDeps(), piGateway: fake.gateway });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(async () => {
|
|
54
|
+
// Wait long enough for the route's deferred setTimeout(process.exit, 200)
|
|
55
|
+
// to fire WHILE the spy is still active, so the mock absorbs it instead
|
|
56
|
+
// of leaking to the real process.exit after mockRestore.
|
|
57
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
58
|
+
await fastify.close();
|
|
59
|
+
exitSpy.mockRestore();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("sends server_restarting with reason=restart and quiesceMs=5000 to bridges before exit", async () => {
|
|
63
|
+
const res = await fastify.inject({ method: "POST", url: "/api/restart", payload: {} });
|
|
64
|
+
// The handler returns ok:true synchronously (orchestrator + exit are deferred).
|
|
65
|
+
expect(res.statusCode).toBe(200);
|
|
66
|
+
expect(res.json()).toEqual({ ok: true });
|
|
67
|
+
expect(broadcasts).toHaveLength(1);
|
|
68
|
+
expect(broadcasts[0]).toEqual({
|
|
69
|
+
type: "server_restarting",
|
|
70
|
+
reason: "restart",
|
|
71
|
+
quiesceMs: 5000,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("POST /api/shutdown broadcasts server_restarting", () => {
|
|
77
|
+
let fastify: FastifyInstance;
|
|
78
|
+
let exitSpy: ReturnType<typeof vi.spyOn>;
|
|
79
|
+
let broadcasts: ServerToExtensionMessage[];
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
fastify = Fastify();
|
|
83
|
+
const fake = makeFakeGateway();
|
|
84
|
+
broadcasts = fake.broadcasts;
|
|
85
|
+
exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => undefined as never));
|
|
86
|
+
registerSystemRoutes(fastify, { ...makeNoopDeps(), piGateway: fake.gateway });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterEach(async () => {
|
|
90
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
91
|
+
await fastify.close();
|
|
92
|
+
exitSpy.mockRestore();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("sends server_restarting with reason=shutdown and quiesceMs=60000", async () => {
|
|
96
|
+
const res = await fastify.inject({ method: "POST", url: "/api/shutdown", payload: {} });
|
|
97
|
+
expect(res.statusCode).toBe(200);
|
|
98
|
+
expect(res.json()).toEqual({ ok: true });
|
|
99
|
+
expect(broadcasts).toHaveLength(1);
|
|
100
|
+
expect(broadcasts[0]).toEqual({
|
|
101
|
+
type: "server_restarting",
|
|
102
|
+
reason: "shutdown",
|
|
103
|
+
quiesceMs: 60000,
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("/api/restart works without piGateway (no-op broadcast)", () => {
|
|
109
|
+
let fastify: FastifyInstance;
|
|
110
|
+
let exitSpy: ReturnType<typeof vi.spyOn>;
|
|
111
|
+
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
fastify = Fastify();
|
|
114
|
+
exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => undefined as never));
|
|
115
|
+
registerSystemRoutes(fastify, makeNoopDeps()); // no piGateway
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
afterEach(async () => {
|
|
119
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
120
|
+
await fastify.close();
|
|
121
|
+
exitSpy.mockRestore();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("does not throw when there is no gateway", async () => {
|
|
125
|
+
const res = await fastify.inject({ method: "POST", url: "/api/restart", payload: {} });
|
|
126
|
+
expect(res.statusCode).toBe(200);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
writeSessionMeta,
|
|
7
|
+
readSessionMeta,
|
|
8
|
+
type SessionMeta,
|
|
9
|
+
} from "@blackbelt-technology/pi-dashboard-shared/session-meta.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Persistence round-trip for `SessionMeta.unread`.
|
|
13
|
+
* See change: session-card-unread-stripes.
|
|
14
|
+
*/
|
|
15
|
+
describe("unread persistence", () => {
|
|
16
|
+
it("round-trips unread=true through .meta.json", () => {
|
|
17
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "unread-meta-"));
|
|
18
|
+
const sessionFile = path.join(dir, "session-1.jsonl");
|
|
19
|
+
fs.writeFileSync(sessionFile, "");
|
|
20
|
+
|
|
21
|
+
const meta: SessionMeta = {
|
|
22
|
+
source: "tui",
|
|
23
|
+
cwd: "/tmp",
|
|
24
|
+
status: "idle",
|
|
25
|
+
unread: true,
|
|
26
|
+
};
|
|
27
|
+
writeSessionMeta(sessionFile, meta);
|
|
28
|
+
|
|
29
|
+
const restored = readSessionMeta(sessionFile);
|
|
30
|
+
expect(restored?.unread).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("round-trips unread=false through .meta.json", () => {
|
|
34
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "unread-meta-"));
|
|
35
|
+
const sessionFile = path.join(dir, "session-2.jsonl");
|
|
36
|
+
fs.writeFileSync(sessionFile, "");
|
|
37
|
+
|
|
38
|
+
writeSessionMeta(sessionFile, { source: "tui", unread: false });
|
|
39
|
+
const restored = readSessionMeta(sessionFile);
|
|
40
|
+
expect(restored?.unread).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("absent unread field is undefined on read (back-compat)", () => {
|
|
44
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "unread-meta-"));
|
|
45
|
+
const sessionFile = path.join(dir, "session-3.jsonl");
|
|
46
|
+
fs.writeFileSync(sessionFile, "");
|
|
47
|
+
fs.writeFileSync(
|
|
48
|
+
path.join(dir, "session-3.meta.json"),
|
|
49
|
+
JSON.stringify({ source: "tui", cwd: "/tmp" }),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const restored = readSessionMeta(sessionFile);
|
|
53
|
+
expect(restored?.unread).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { WebSocket } from "ws";
|
|
3
|
+
import { createServer, type DashboardServer } from "../server.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* End-to-end wiring test for `session.unread`:
|
|
7
|
+
* - trigger fires & not viewed → unread broadcast
|
|
8
|
+
* - trigger fires & viewed → no unread
|
|
9
|
+
* - replay events do not trigger unread
|
|
10
|
+
*
|
|
11
|
+
* See change: session-card-unread-stripes.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
async function connectSession(piPort: number, sessionId: string): Promise<WebSocket> {
|
|
15
|
+
const ws = new WebSocket(`ws://localhost:${piPort}`);
|
|
16
|
+
await new Promise<void>((resolve) => {
|
|
17
|
+
ws.on("open", () => {
|
|
18
|
+
ws.send(JSON.stringify({
|
|
19
|
+
type: "session_register",
|
|
20
|
+
sessionId,
|
|
21
|
+
cwd: "/tmp",
|
|
22
|
+
source: "cli",
|
|
23
|
+
}));
|
|
24
|
+
ws.send(JSON.stringify({ type: "replay_complete", sessionId }));
|
|
25
|
+
setTimeout(resolve, 60);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
return ws;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function connectBrowser(browserPort: number, sessionId: string): Promise<{
|
|
32
|
+
ws: WebSocket;
|
|
33
|
+
broadcasts: Array<Record<string, unknown>>;
|
|
34
|
+
}> {
|
|
35
|
+
const ws = new WebSocket(`ws://localhost:${browserPort}/ws`);
|
|
36
|
+
const broadcasts: Array<Record<string, unknown>> = [];
|
|
37
|
+
await new Promise<void>((resolve) => {
|
|
38
|
+
ws.on("open", () => {
|
|
39
|
+
ws.on("message", (raw) => {
|
|
40
|
+
try {
|
|
41
|
+
const msg = JSON.parse(raw.toString());
|
|
42
|
+
if (msg.type === "session_updated" && msg.sessionId === sessionId) {
|
|
43
|
+
broadcasts.push(msg);
|
|
44
|
+
}
|
|
45
|
+
} catch { /* ignore */ }
|
|
46
|
+
});
|
|
47
|
+
ws.send(JSON.stringify({ type: "subscribe", sessionId }));
|
|
48
|
+
setTimeout(resolve, 80);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
return { ws, broadcasts };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sendEvent(
|
|
55
|
+
ws: WebSocket,
|
|
56
|
+
sessionId: string,
|
|
57
|
+
eventType: string,
|
|
58
|
+
data: Record<string, unknown> = {},
|
|
59
|
+
): void {
|
|
60
|
+
ws.send(JSON.stringify({
|
|
61
|
+
type: "event_forward",
|
|
62
|
+
sessionId,
|
|
63
|
+
event: { eventType, timestamp: Date.now(), data },
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
68
|
+
|
|
69
|
+
describe("unread trigger — server wiring", () => {
|
|
70
|
+
let server: DashboardServer;
|
|
71
|
+
let piPort: number;
|
|
72
|
+
let browserPort: number;
|
|
73
|
+
let testPort = 19400;
|
|
74
|
+
|
|
75
|
+
beforeEach(async () => {
|
|
76
|
+
testPort += 2;
|
|
77
|
+
browserPort = testPort;
|
|
78
|
+
piPort = testPort + 1;
|
|
79
|
+
server = await createServer({
|
|
80
|
+
port: browserPort,
|
|
81
|
+
piPort,
|
|
82
|
+
dev: true,
|
|
83
|
+
autoShutdown: false,
|
|
84
|
+
shutdownIdleSeconds: 999,
|
|
85
|
+
tunnel: false,
|
|
86
|
+
editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
|
|
87
|
+
});
|
|
88
|
+
await server.start();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterEach(async () => {
|
|
92
|
+
await server.stop();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("streaming → idle while NOT viewed marks the session unread and broadcasts", async () => {
|
|
96
|
+
const piWs = await connectSession(piPort, "u1");
|
|
97
|
+
const { ws: browser, broadcasts } = await connectBrowser(browserPort, "u1");
|
|
98
|
+
|
|
99
|
+
// Drive the session into streaming
|
|
100
|
+
sendEvent(piWs, "u1", "agent_start");
|
|
101
|
+
await wait(60);
|
|
102
|
+
expect(server.sessionManager.get("u1")?.status).toBe("streaming");
|
|
103
|
+
|
|
104
|
+
// Now finish the turn — agent_end transitions back to idle
|
|
105
|
+
sendEvent(piWs, "u1", "agent_end");
|
|
106
|
+
await wait(120);
|
|
107
|
+
|
|
108
|
+
const session = server.sessionManager.get("u1");
|
|
109
|
+
expect(session?.unread).toBe(true);
|
|
110
|
+
|
|
111
|
+
const unreadTrue = broadcasts.filter(
|
|
112
|
+
(b) => (b.updates as Record<string, unknown> | undefined)?.unread === true,
|
|
113
|
+
);
|
|
114
|
+
expect(unreadTrue.length).toBeGreaterThanOrEqual(1);
|
|
115
|
+
|
|
116
|
+
piWs.close();
|
|
117
|
+
browser.close();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("trigger fires while a browser IS viewing → unread stays false", async () => {
|
|
121
|
+
const piWs = await connectSession(piPort, "u2");
|
|
122
|
+
const { ws: browser, broadcasts } = await connectBrowser(browserPort, "u2");
|
|
123
|
+
|
|
124
|
+
// Browser declares it is viewing the session
|
|
125
|
+
browser.send(JSON.stringify({ type: "session_view", sessionId: "u2" }));
|
|
126
|
+
await wait(60);
|
|
127
|
+
|
|
128
|
+
sendEvent(piWs, "u2", "agent_start");
|
|
129
|
+
await wait(60);
|
|
130
|
+
sendEvent(piWs, "u2", "agent_end");
|
|
131
|
+
await wait(120);
|
|
132
|
+
|
|
133
|
+
const session = server.sessionManager.get("u2");
|
|
134
|
+
expect(session?.unread).toBeFalsy();
|
|
135
|
+
|
|
136
|
+
const unreadTrue = broadcasts.filter(
|
|
137
|
+
(b) => (b.updates as Record<string, unknown> | undefined)?.unread === true,
|
|
138
|
+
);
|
|
139
|
+
expect(unreadTrue.length).toBe(0);
|
|
140
|
+
|
|
141
|
+
piWs.close();
|
|
142
|
+
browser.close();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("replay events do not flip unread", async () => {
|
|
146
|
+
const ws = new WebSocket(`ws://localhost:${piPort}`);
|
|
147
|
+
await new Promise<void>((resolve) => {
|
|
148
|
+
ws.on("open", () => {
|
|
149
|
+
ws.send(JSON.stringify({
|
|
150
|
+
type: "session_register",
|
|
151
|
+
sessionId: "u3",
|
|
152
|
+
cwd: "/tmp",
|
|
153
|
+
source: "cli",
|
|
154
|
+
}));
|
|
155
|
+
// Send a replayable agent_start/agent_end pair BEFORE replay_complete.
|
|
156
|
+
sendEvent(ws, "u3", "agent_start");
|
|
157
|
+
sendEvent(ws, "u3", "agent_end");
|
|
158
|
+
setTimeout(resolve, 150);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const session = server.sessionManager.get("u3");
|
|
163
|
+
expect(session?.unread).toBeFalsy();
|
|
164
|
+
|
|
165
|
+
ws.close();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("session_view clears unread and broadcasts unread=false", async () => {
|
|
169
|
+
const piWs = await connectSession(piPort, "u4");
|
|
170
|
+
const { ws: browser, broadcasts } = await connectBrowser(browserPort, "u4");
|
|
171
|
+
|
|
172
|
+
// Drive into unread (browser not viewing)
|
|
173
|
+
sendEvent(piWs, "u4", "agent_start");
|
|
174
|
+
await wait(50);
|
|
175
|
+
sendEvent(piWs, "u4", "agent_end");
|
|
176
|
+
await wait(120);
|
|
177
|
+
expect(server.sessionManager.get("u4")?.unread).toBe(true);
|
|
178
|
+
|
|
179
|
+
// Now declare view
|
|
180
|
+
browser.send(JSON.stringify({ type: "session_view", sessionId: "u4" }));
|
|
181
|
+
await wait(80);
|
|
182
|
+
|
|
183
|
+
expect(server.sessionManager.get("u4")?.unread).toBe(false);
|
|
184
|
+
|
|
185
|
+
const unreadFalseBroadcast = broadcasts.find(
|
|
186
|
+
(b) => (b.updates as Record<string, unknown> | undefined)?.unread === false,
|
|
187
|
+
);
|
|
188
|
+
expect(unreadFalseBroadcast).toBeDefined();
|
|
189
|
+
|
|
190
|
+
piWs.close();
|
|
191
|
+
browser.close();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("session_view on an already-read session does not produce a redundant broadcast", async () => {
|
|
195
|
+
const piWs = await connectSession(piPort, "u5");
|
|
196
|
+
const { ws: browser, broadcasts } = await connectBrowser(browserPort, "u5");
|
|
197
|
+
|
|
198
|
+
browser.send(JSON.stringify({ type: "session_view", sessionId: "u5" }));
|
|
199
|
+
browser.send(JSON.stringify({ type: "session_view", sessionId: "u5" }));
|
|
200
|
+
await wait(80);
|
|
201
|
+
|
|
202
|
+
const unreadBroadcasts = broadcasts.filter(
|
|
203
|
+
(b) => (b.updates as Record<string, unknown> | undefined)?.unread !== undefined,
|
|
204
|
+
);
|
|
205
|
+
expect(unreadBroadcasts.length).toBe(0);
|
|
206
|
+
|
|
207
|
+
piWs.close();
|
|
208
|
+
browser.close();
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import type { WebSocket } from "ws";
|
|
3
|
+
import { createViewedSessionTracker } from "../viewed-session-tracker.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Server-side viewed-session tracker. Mirrors mail/Slack global read state.
|
|
7
|
+
* See change: session-card-unread-stripes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Lightweight stand-ins for `ws.WebSocket`. The tracker only uses the
|
|
11
|
+
// references for identity in `Set<WebSocket>`, so we don't need real sockets.
|
|
12
|
+
function fakeWs(label: string): WebSocket {
|
|
13
|
+
return { __label: label } as unknown as WebSocket;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("createViewedSessionTracker", () => {
|
|
17
|
+
it("starts empty: no session is viewed", () => {
|
|
18
|
+
const t = createViewedSessionTracker();
|
|
19
|
+
expect(t.isViewedByAnyone("abc")).toBe(false);
|
|
20
|
+
expect(t.viewerCount("abc")).toBe(0);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("view() makes a session viewed", () => {
|
|
24
|
+
const t = createViewedSessionTracker();
|
|
25
|
+
const ws = fakeWs("a");
|
|
26
|
+
t.view("abc", ws);
|
|
27
|
+
expect(t.isViewedByAnyone("abc")).toBe(true);
|
|
28
|
+
expect(t.viewerCount("abc")).toBe(1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("view() is idempotent for the same ws", () => {
|
|
32
|
+
const t = createViewedSessionTracker();
|
|
33
|
+
const ws = fakeWs("a");
|
|
34
|
+
t.view("abc", ws);
|
|
35
|
+
t.view("abc", ws);
|
|
36
|
+
expect(t.viewerCount("abc")).toBe(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("two viewers, one disconnects → still viewed", () => {
|
|
40
|
+
const t = createViewedSessionTracker();
|
|
41
|
+
const a = fakeWs("a");
|
|
42
|
+
const b = fakeWs("b");
|
|
43
|
+
t.view("abc", a);
|
|
44
|
+
t.view("abc", b);
|
|
45
|
+
expect(t.viewerCount("abc")).toBe(2);
|
|
46
|
+
t.unview("abc", a);
|
|
47
|
+
expect(t.isViewedByAnyone("abc")).toBe(true);
|
|
48
|
+
expect(t.viewerCount("abc")).toBe(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("last viewer unviews → no longer viewed", () => {
|
|
52
|
+
const t = createViewedSessionTracker();
|
|
53
|
+
const ws = fakeWs("a");
|
|
54
|
+
t.view("abc", ws);
|
|
55
|
+
t.unview("abc", ws);
|
|
56
|
+
expect(t.isViewedByAnyone("abc")).toBe(false);
|
|
57
|
+
expect(t.viewerCount("abc")).toBe(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("unview() of an unknown session is a no-op", () => {
|
|
61
|
+
const t = createViewedSessionTracker();
|
|
62
|
+
const ws = fakeWs("a");
|
|
63
|
+
expect(() => t.unview("never-seen", ws)).not.toThrow();
|
|
64
|
+
expect(t.isViewedByAnyone("never-seen")).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("unviewAll() removes a ws from every session", () => {
|
|
68
|
+
const t = createViewedSessionTracker();
|
|
69
|
+
const a = fakeWs("a");
|
|
70
|
+
const b = fakeWs("b");
|
|
71
|
+
t.view("s1", a);
|
|
72
|
+
t.view("s2", a);
|
|
73
|
+
t.view("s2", b);
|
|
74
|
+
t.unviewAll(a);
|
|
75
|
+
expect(t.isViewedByAnyone("s1")).toBe(false);
|
|
76
|
+
expect(t.isViewedByAnyone("s2")).toBe(true); // b still views s2
|
|
77
|
+
expect(t.viewerCount("s2")).toBe(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("unviewAll() with no viewing sessions is a no-op", () => {
|
|
81
|
+
const t = createViewedSessionTracker();
|
|
82
|
+
const ws = fakeWs("a");
|
|
83
|
+
expect(() => t.unviewAll(ws)).not.toThrow();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("multiple sessions are tracked independently", () => {
|
|
87
|
+
const t = createViewedSessionTracker();
|
|
88
|
+
const ws = fakeWs("a");
|
|
89
|
+
t.view("s1", ws);
|
|
90
|
+
expect(t.isViewedByAnyone("s1")).toBe(true);
|
|
91
|
+
expect(t.isViewedByAnyone("s2")).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|