@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
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { isUnreadTrigger } from "../event-status-extraction.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Unread-trigger classifier for `session.unread` flipping to true.
|
|
6
|
+
* See change: session-card-unread-stripes.
|
|
7
|
+
*/
|
|
8
|
+
describe("isUnreadTrigger", () => {
|
|
9
|
+
describe("trigger 1: streaming -> quiescent (turn finished)", () => {
|
|
10
|
+
it("returns true on streaming -> idle", () => {
|
|
11
|
+
expect(
|
|
12
|
+
isUnreadTrigger(
|
|
13
|
+
"agent_end",
|
|
14
|
+
{ status: "streaming", currentTool: null },
|
|
15
|
+
{ status: "idle", currentTool: null },
|
|
16
|
+
),
|
|
17
|
+
).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("returns true on streaming -> active", () => {
|
|
21
|
+
expect(
|
|
22
|
+
isUnreadTrigger(
|
|
23
|
+
"agent_end",
|
|
24
|
+
{ status: "streaming", currentTool: null },
|
|
25
|
+
{ status: "active", currentTool: null },
|
|
26
|
+
),
|
|
27
|
+
).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns false on idle -> idle (no transition)", () => {
|
|
31
|
+
expect(
|
|
32
|
+
isUnreadTrigger(
|
|
33
|
+
"tool_execution_end",
|
|
34
|
+
{ status: "idle", currentTool: null },
|
|
35
|
+
{ status: "idle", currentTool: null },
|
|
36
|
+
),
|
|
37
|
+
).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns false on active -> idle (was not streaming)", () => {
|
|
41
|
+
expect(
|
|
42
|
+
isUnreadTrigger(
|
|
43
|
+
"agent_end",
|
|
44
|
+
{ status: "active", currentTool: null },
|
|
45
|
+
{ status: "idle", currentTool: null },
|
|
46
|
+
),
|
|
47
|
+
).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns false on streaming -> streaming", () => {
|
|
51
|
+
expect(
|
|
52
|
+
isUnreadTrigger(
|
|
53
|
+
"message_end",
|
|
54
|
+
{ status: "streaming", currentTool: null },
|
|
55
|
+
{ status: "streaming", currentTool: null },
|
|
56
|
+
),
|
|
57
|
+
).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns false on streaming -> ended (death is not unread)", () => {
|
|
61
|
+
// streaming -> ended is also a transition out of streaming, but per
|
|
62
|
+
// design.md the unread state is only about quiescent-alive sessions.
|
|
63
|
+
// Status "ended" should NOT trigger unread on its own. (If we want
|
|
64
|
+
// to mark ended sessions as needing attention, that's a separate
|
|
65
|
+
// requirement.)
|
|
66
|
+
expect(
|
|
67
|
+
isUnreadTrigger(
|
|
68
|
+
"agent_end",
|
|
69
|
+
{ status: "streaming", currentTool: null },
|
|
70
|
+
// @ts-expect-error simulating the broader status union
|
|
71
|
+
{ status: "ended", currentTool: null },
|
|
72
|
+
),
|
|
73
|
+
).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("trigger 2: currentTool becomes ask_user", () => {
|
|
78
|
+
it("returns true when currentTool flips from null to ask_user", () => {
|
|
79
|
+
expect(
|
|
80
|
+
isUnreadTrigger(
|
|
81
|
+
"tool_execution_start",
|
|
82
|
+
{ status: "streaming", currentTool: null },
|
|
83
|
+
{ status: "streaming", currentTool: "ask_user" },
|
|
84
|
+
),
|
|
85
|
+
).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("returns true when currentTool flips from another tool to ask_user", () => {
|
|
89
|
+
expect(
|
|
90
|
+
isUnreadTrigger(
|
|
91
|
+
"tool_execution_start",
|
|
92
|
+
{ status: "streaming", currentTool: "Read" },
|
|
93
|
+
{ status: "streaming", currentTool: "ask_user" },
|
|
94
|
+
),
|
|
95
|
+
).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns false when ask_user persists (no transition)", () => {
|
|
99
|
+
expect(
|
|
100
|
+
isUnreadTrigger(
|
|
101
|
+
"message_end",
|
|
102
|
+
{ status: "streaming", currentTool: "ask_user" },
|
|
103
|
+
{ status: "streaming", currentTool: "ask_user" },
|
|
104
|
+
),
|
|
105
|
+
).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("trigger 3: agent_end with error", () => {
|
|
110
|
+
it("returns true when payload.error is set", () => {
|
|
111
|
+
expect(
|
|
112
|
+
isUnreadTrigger(
|
|
113
|
+
"agent_end",
|
|
114
|
+
{ status: "streaming", currentTool: null },
|
|
115
|
+
{ status: "streaming", currentTool: null },
|
|
116
|
+
{ error: "rate limit exceeded" },
|
|
117
|
+
),
|
|
118
|
+
).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns false when agent_end has no error", () => {
|
|
122
|
+
expect(
|
|
123
|
+
isUnreadTrigger(
|
|
124
|
+
"agent_end",
|
|
125
|
+
{ status: "streaming", currentTool: null },
|
|
126
|
+
{ status: "streaming", currentTool: null },
|
|
127
|
+
{},
|
|
128
|
+
),
|
|
129
|
+
).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("returns false on agent_end with no payload", () => {
|
|
133
|
+
expect(
|
|
134
|
+
isUnreadTrigger(
|
|
135
|
+
"agent_end",
|
|
136
|
+
{ status: "streaming", currentTool: null },
|
|
137
|
+
{ status: "streaming", currentTool: null },
|
|
138
|
+
),
|
|
139
|
+
).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("non-triggers (intentional false)", () => {
|
|
144
|
+
const states = { status: "streaming", currentTool: null } as const;
|
|
145
|
+
|
|
146
|
+
it.each([
|
|
147
|
+
"message_start",
|
|
148
|
+
"message_end",
|
|
149
|
+
"tool_execution_start",
|
|
150
|
+
"tool_execution_end",
|
|
151
|
+
"model_select",
|
|
152
|
+
"git_info_update",
|
|
153
|
+
"process_metrics",
|
|
154
|
+
"ui_modules_list",
|
|
155
|
+
"bash_output",
|
|
156
|
+
])("returns false for %s when state is unchanged", (eventType) => {
|
|
157
|
+
expect(isUnreadTrigger(eventType, states, states)).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns false for unknown event types", () => {
|
|
161
|
+
expect(isUnreadTrigger("totally_made_up_event", states, states)).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
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
|
+
* Tests for `lastActivityAt` server-side stamping + 30s debounced broadcast.
|
|
7
|
+
* See change: session-card-last-activity-badge.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
async function connectSession(piPort: number, sessionId: string): Promise<WebSocket> {
|
|
11
|
+
const ws = new WebSocket(`ws://localhost:${piPort}`);
|
|
12
|
+
await new Promise<void>((resolve) => {
|
|
13
|
+
ws.on("open", () => {
|
|
14
|
+
ws.send(JSON.stringify({
|
|
15
|
+
type: "session_register",
|
|
16
|
+
sessionId,
|
|
17
|
+
cwd: "/tmp",
|
|
18
|
+
source: "cli",
|
|
19
|
+
}));
|
|
20
|
+
ws.send(JSON.stringify({ type: "replay_complete", sessionId }));
|
|
21
|
+
setTimeout(resolve, 50);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
return ws;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function connectBrowser(browserPort: number, sessionId: string): Promise<{
|
|
28
|
+
ws: WebSocket;
|
|
29
|
+
broadcasts: Array<Record<string, unknown>>;
|
|
30
|
+
}> {
|
|
31
|
+
const ws = new WebSocket(`ws://localhost:${browserPort}/ws`);
|
|
32
|
+
const broadcasts: Array<Record<string, unknown>> = [];
|
|
33
|
+
await new Promise<void>((resolve) => {
|
|
34
|
+
ws.on("open", () => {
|
|
35
|
+
ws.on("message", (raw) => {
|
|
36
|
+
try {
|
|
37
|
+
const msg = JSON.parse(raw.toString());
|
|
38
|
+
if (msg.type === "session_updated" && msg.sessionId === sessionId) {
|
|
39
|
+
broadcasts.push(msg);
|
|
40
|
+
}
|
|
41
|
+
} catch { /* ignore */ }
|
|
42
|
+
});
|
|
43
|
+
ws.send(JSON.stringify({ type: "subscribe", sessionId }));
|
|
44
|
+
setTimeout(resolve, 80);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
return { ws, broadcasts };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sendActivityEvent(ws: WebSocket, sessionId: string, eventType: string): void {
|
|
51
|
+
ws.send(JSON.stringify({
|
|
52
|
+
type: "event_forward",
|
|
53
|
+
sessionId,
|
|
54
|
+
event: {
|
|
55
|
+
eventType,
|
|
56
|
+
timestamp: Date.now(),
|
|
57
|
+
data: {},
|
|
58
|
+
},
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
63
|
+
|
|
64
|
+
describe("lastActivityAt — server stamping and debounce", () => {
|
|
65
|
+
let server: DashboardServer;
|
|
66
|
+
let piPort: number;
|
|
67
|
+
let browserPort: number;
|
|
68
|
+
let testPort = 19200;
|
|
69
|
+
|
|
70
|
+
beforeEach(async () => {
|
|
71
|
+
testPort += 2;
|
|
72
|
+
browserPort = testPort;
|
|
73
|
+
piPort = testPort + 1;
|
|
74
|
+
server = await createServer({
|
|
75
|
+
port: browserPort,
|
|
76
|
+
piPort,
|
|
77
|
+
dev: true,
|
|
78
|
+
autoShutdown: false,
|
|
79
|
+
shutdownIdleSeconds: 999,
|
|
80
|
+
tunnel: false,
|
|
81
|
+
editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
|
|
82
|
+
});
|
|
83
|
+
await server.start();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterEach(async () => {
|
|
87
|
+
await server.stop();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("stamps lastActivityAt on an activity event and broadcasts immediately the first time", async () => {
|
|
91
|
+
const ws = await connectSession(piPort, "a1");
|
|
92
|
+
const { ws: browser, broadcasts } = await connectBrowser(browserPort, "a1");
|
|
93
|
+
|
|
94
|
+
const before = Date.now();
|
|
95
|
+
sendActivityEvent(ws, "a1", "message_start");
|
|
96
|
+
await wait(120);
|
|
97
|
+
|
|
98
|
+
const session = server.sessionManager.get("a1");
|
|
99
|
+
expect(session?.lastActivityAt).toBeDefined();
|
|
100
|
+
expect(session!.lastActivityAt!).toBeGreaterThanOrEqual(before);
|
|
101
|
+
|
|
102
|
+
const lastActivityBroadcasts = broadcasts.filter(
|
|
103
|
+
(b) => (b.updates as Record<string, unknown> | undefined)?.lastActivityAt !== undefined,
|
|
104
|
+
);
|
|
105
|
+
expect(lastActivityBroadcasts.length).toBeGreaterThanOrEqual(1);
|
|
106
|
+
|
|
107
|
+
ws.close();
|
|
108
|
+
browser.close();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("does NOT broadcast lastActivityAt for non-activity events", async () => {
|
|
112
|
+
const ws = await connectSession(piPort, "a2");
|
|
113
|
+
const { ws: browser, broadcasts } = await connectBrowser(browserPort, "a2");
|
|
114
|
+
|
|
115
|
+
sendActivityEvent(ws, "a2", "process_metrics");
|
|
116
|
+
sendActivityEvent(ws, "a2", "git_info_update");
|
|
117
|
+
sendActivityEvent(ws, "a2", "ui_data_list");
|
|
118
|
+
await wait(120);
|
|
119
|
+
|
|
120
|
+
const session = server.sessionManager.get("a2");
|
|
121
|
+
expect(session?.lastActivityAt).toBeUndefined();
|
|
122
|
+
|
|
123
|
+
const lastActivityBroadcasts = broadcasts.filter(
|
|
124
|
+
(b) => (b.updates as Record<string, unknown> | undefined)?.lastActivityAt !== undefined,
|
|
125
|
+
);
|
|
126
|
+
expect(lastActivityBroadcasts.length).toBe(0);
|
|
127
|
+
|
|
128
|
+
ws.close();
|
|
129
|
+
browser.close();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("debounces subsequent broadcasts within the 30s window — in-memory still updates", async () => {
|
|
133
|
+
const ws = await connectSession(piPort, "a3");
|
|
134
|
+
const { ws: browser, broadcasts } = await connectBrowser(browserPort, "a3");
|
|
135
|
+
|
|
136
|
+
sendActivityEvent(ws, "a3", "tool_execution_start");
|
|
137
|
+
await wait(120);
|
|
138
|
+
|
|
139
|
+
const t1 = server.sessionManager.get("a3")?.lastActivityAt;
|
|
140
|
+
expect(t1).toBeDefined();
|
|
141
|
+
const broadcastCountAfterFirst = broadcasts.filter(
|
|
142
|
+
(b) => (b.updates as Record<string, unknown> | undefined)?.lastActivityAt !== undefined,
|
|
143
|
+
).length;
|
|
144
|
+
expect(broadcastCountAfterFirst).toBeGreaterThanOrEqual(1);
|
|
145
|
+
|
|
146
|
+
// Send several more activity events well within the 30s debounce window
|
|
147
|
+
await wait(200);
|
|
148
|
+
sendActivityEvent(ws, "a3", "tool_execution_end");
|
|
149
|
+
sendActivityEvent(ws, "a3", "message_end");
|
|
150
|
+
sendActivityEvent(ws, "a3", "turn_end");
|
|
151
|
+
await wait(120);
|
|
152
|
+
|
|
153
|
+
const t2 = server.sessionManager.get("a3")?.lastActivityAt;
|
|
154
|
+
expect(t2).toBeDefined();
|
|
155
|
+
expect(t2!).toBeGreaterThan(t1!); // in-memory advances on every activity event
|
|
156
|
+
|
|
157
|
+
const broadcastCountAfterMore = broadcasts.filter(
|
|
158
|
+
(b) => (b.updates as Record<string, unknown> | undefined)?.lastActivityAt !== undefined,
|
|
159
|
+
).length;
|
|
160
|
+
// No new lastActivityAt-only broadcast within 30s.
|
|
161
|
+
expect(broadcastCountAfterMore).toBe(broadcastCountAfterFirst);
|
|
162
|
+
|
|
163
|
+
ws.close();
|
|
164
|
+
browser.close();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("does not stamp lastActivityAt during replay (events arriving before replay_complete)", async () => {
|
|
168
|
+
// Connect raw without sending replay_complete — event-wiring treats events as replay
|
|
169
|
+
const ws = new WebSocket(`ws://localhost:${piPort}`);
|
|
170
|
+
await new Promise<void>((resolve) => {
|
|
171
|
+
ws.on("open", () => {
|
|
172
|
+
ws.send(JSON.stringify({
|
|
173
|
+
type: "session_register",
|
|
174
|
+
sessionId: "a4",
|
|
175
|
+
cwd: "/tmp",
|
|
176
|
+
source: "cli",
|
|
177
|
+
}));
|
|
178
|
+
// Intentionally NO replay_complete here.
|
|
179
|
+
sendActivityEvent(ws, "a4", "message_end");
|
|
180
|
+
sendActivityEvent(ws, "a4", "tool_execution_start");
|
|
181
|
+
setTimeout(resolve, 150);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const session = server.sessionManager.get("a4");
|
|
186
|
+
expect(session?.lastActivityAt).toBeUndefined();
|
|
187
|
+
|
|
188
|
+
ws.close();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `applyReattachPolicy` helper and its pure
|
|
3
|
+
* `decideReattachAction` decision function.
|
|
4
|
+
*
|
|
5
|
+
* Covers the matrix from `specs/session-ordering` ADDED Requirement
|
|
6
|
+
* "Reattach placement policy applied on register":
|
|
7
|
+
* policy × session.status → moveToFront | preserve
|
|
8
|
+
*
|
|
9
|
+
* See change: reattach-move-to-front.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
12
|
+
import {
|
|
13
|
+
decideReattachAction,
|
|
14
|
+
applyReattachPolicy,
|
|
15
|
+
} from "../reattach-placement.js";
|
|
16
|
+
import { createMemorySessionManager, type SessionManager } from "../memory-session-manager.js";
|
|
17
|
+
import { createSessionOrderManager, type SessionOrderManager } from "../session-order-manager.js";
|
|
18
|
+
import type { PreferencesStore } from "../preferences-store.js";
|
|
19
|
+
import type { BrowserGateway } from "../browser-gateway.js";
|
|
20
|
+
|
|
21
|
+
function makePrefs(): PreferencesStore {
|
|
22
|
+
let order: Record<string, string[]> = {};
|
|
23
|
+
return {
|
|
24
|
+
getSessionOrder: () => order,
|
|
25
|
+
setSessionOrder: (o) => { order = o; },
|
|
26
|
+
getPinnedDirectories: () => [],
|
|
27
|
+
setPinnedDirectories: () => {},
|
|
28
|
+
pinDirectory: () => {},
|
|
29
|
+
unpinDirectory: () => {},
|
|
30
|
+
reorderPinnedDirs: () => {},
|
|
31
|
+
flush: () => {},
|
|
32
|
+
dispose: () => {},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeBroadcastingGateway() {
|
|
37
|
+
const broadcasts: any[] = [];
|
|
38
|
+
const gateway = {
|
|
39
|
+
broadcastToAll: (msg: any) => { broadcasts.push(msg); },
|
|
40
|
+
// Other BrowserGateway members are unused by the helper.
|
|
41
|
+
} as unknown as BrowserGateway;
|
|
42
|
+
return { gateway, broadcasts };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("decideReattachAction (pure)", () => {
|
|
46
|
+
it("'always' moves on any non-ended status", () => {
|
|
47
|
+
expect(decideReattachAction("always", "active")).toBe("moveToFront");
|
|
48
|
+
expect(decideReattachAction("always", "streaming")).toBe("moveToFront");
|
|
49
|
+
expect(decideReattachAction("always", "idle")).toBe("moveToFront");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("'streaming-only' moves only when status is 'streaming'", () => {
|
|
53
|
+
expect(decideReattachAction("streaming-only", "streaming")).toBe("moveToFront");
|
|
54
|
+
expect(decideReattachAction("streaming-only", "active")).toBe("preserve");
|
|
55
|
+
expect(decideReattachAction("streaming-only", "idle")).toBe("preserve");
|
|
56
|
+
expect(decideReattachAction("streaming-only", "ended")).toBe("preserve");
|
|
57
|
+
expect(decideReattachAction("streaming-only", undefined)).toBe("preserve");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("'preserve' never moves", () => {
|
|
61
|
+
expect(decideReattachAction("preserve", "streaming")).toBe("preserve");
|
|
62
|
+
expect(decideReattachAction("preserve", "active")).toBe("preserve");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("applyReattachPolicy (I/O)", () => {
|
|
67
|
+
const cwd = "/proj";
|
|
68
|
+
let sessionManager: SessionManager;
|
|
69
|
+
let sessionOrderManager: SessionOrderManager;
|
|
70
|
+
let gateway: BrowserGateway;
|
|
71
|
+
let broadcasts: any[];
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
sessionManager = createMemorySessionManager();
|
|
75
|
+
sessionOrderManager = createSessionOrderManager(makePrefs());
|
|
76
|
+
const gw = makeBroadcastingGateway();
|
|
77
|
+
gateway = gw.gateway;
|
|
78
|
+
broadcasts = gw.broadcasts;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
function setupSession(id: string, status: "active" | "streaming" | "idle" | "ended" = "active") {
|
|
82
|
+
sessionManager.register({ id, cwd, source: "tui" });
|
|
83
|
+
if (status !== "active") sessionManager.update(id, { status });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
it("'always' moves a buried session to index 0 and broadcasts", () => {
|
|
87
|
+
setupSession("A");
|
|
88
|
+
setupSession("B");
|
|
89
|
+
setupSession("C");
|
|
90
|
+
sessionOrderManager.reorder(cwd, ["A", "B", "C"]);
|
|
91
|
+
broadcasts.length = 0;
|
|
92
|
+
|
|
93
|
+
const action = applyReattachPolicy("C", cwd, "always", {
|
|
94
|
+
sessionManager,
|
|
95
|
+
sessionOrderManager,
|
|
96
|
+
browserGateway: gateway,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(action).toBe("moveToFront");
|
|
100
|
+
expect(sessionOrderManager.getOrder(cwd)).toEqual(["C", "A", "B"]);
|
|
101
|
+
expect(broadcasts).toHaveLength(1);
|
|
102
|
+
expect(broadcasts[0]).toMatchObject({
|
|
103
|
+
type: "sessions_reordered",
|
|
104
|
+
cwd,
|
|
105
|
+
sessionIds: ["C", "A", "B"],
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("'streaming-only' moves a streaming session", () => {
|
|
110
|
+
setupSession("A");
|
|
111
|
+
setupSession("B", "streaming");
|
|
112
|
+
sessionOrderManager.reorder(cwd, ["A", "B"]);
|
|
113
|
+
broadcasts.length = 0;
|
|
114
|
+
|
|
115
|
+
const action = applyReattachPolicy("B", cwd, "streaming-only", {
|
|
116
|
+
sessionManager,
|
|
117
|
+
sessionOrderManager,
|
|
118
|
+
browserGateway: gateway,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(action).toBe("moveToFront");
|
|
122
|
+
expect(sessionOrderManager.getOrder(cwd)).toEqual(["B", "A"]);
|
|
123
|
+
expect(broadcasts).toHaveLength(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("'streaming-only' does NOT move a non-streaming session", () => {
|
|
127
|
+
setupSession("A");
|
|
128
|
+
setupSession("B", "active");
|
|
129
|
+
sessionOrderManager.reorder(cwd, ["A", "B"]);
|
|
130
|
+
broadcasts.length = 0;
|
|
131
|
+
|
|
132
|
+
const action = applyReattachPolicy("B", cwd, "streaming-only", {
|
|
133
|
+
sessionManager,
|
|
134
|
+
sessionOrderManager,
|
|
135
|
+
browserGateway: gateway,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(action).toBe("preserve");
|
|
139
|
+
expect(sessionOrderManager.getOrder(cwd)).toEqual(["A", "B"]);
|
|
140
|
+
expect(broadcasts).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("'preserve' never moves regardless of status", () => {
|
|
144
|
+
setupSession("A");
|
|
145
|
+
setupSession("B", "streaming");
|
|
146
|
+
sessionOrderManager.reorder(cwd, ["A", "B"]);
|
|
147
|
+
broadcasts.length = 0;
|
|
148
|
+
|
|
149
|
+
const action = applyReattachPolicy("B", cwd, "preserve", {
|
|
150
|
+
sessionManager,
|
|
151
|
+
sessionOrderManager,
|
|
152
|
+
browserGateway: gateway,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(action).toBe("preserve");
|
|
156
|
+
expect(sessionOrderManager.getOrder(cwd)).toEqual(["A", "B"]);
|
|
157
|
+
expect(broadcasts).toEqual([]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("no-ops if session is missing from manager", () => {
|
|
161
|
+
sessionOrderManager.reorder(cwd, ["A", "B"]);
|
|
162
|
+
broadcasts.length = 0;
|
|
163
|
+
|
|
164
|
+
const action = applyReattachPolicy("ghost", cwd, "always", {
|
|
165
|
+
sessionManager,
|
|
166
|
+
sessionOrderManager,
|
|
167
|
+
browserGateway: gateway,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(action).toBe("preserve");
|
|
171
|
+
expect(broadcasts).toEqual([]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("'streaming-only' honors priorStatus when post-register status is 'active' (regression: register() coerces status)", () => {
|
|
175
|
+
// Repro: pre-restart the session was streaming; the dashboard restarts;
|
|
176
|
+
// the bridge re-registers; `register()` overwrites status to "active".
|
|
177
|
+
// Without priorStatus, applyReattachPolicy would see status: "active"
|
|
178
|
+
// and `streaming-only` would be a silent no-op.
|
|
179
|
+
setupSession("A");
|
|
180
|
+
setupSession("B"); // status defaults to "active" post-register
|
|
181
|
+
sessionOrderManager.reorder(cwd, ["A", "B"]);
|
|
182
|
+
broadcasts.length = 0;
|
|
183
|
+
|
|
184
|
+
// Pass priorStatus: "streaming" (what register() saw before overwriting)
|
|
185
|
+
const action = applyReattachPolicy(
|
|
186
|
+
"B",
|
|
187
|
+
cwd,
|
|
188
|
+
"streaming-only",
|
|
189
|
+
{ sessionManager, sessionOrderManager, browserGateway: gateway },
|
|
190
|
+
"streaming",
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
expect(action).toBe("moveToFront");
|
|
194
|
+
expect(sessionOrderManager.getOrder(cwd)).toEqual(["B", "A"]);
|
|
195
|
+
expect(broadcasts).toHaveLength(1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("'streaming-only' falls back to session.status when priorStatus is undefined", () => {
|
|
199
|
+
setupSession("A");
|
|
200
|
+
setupSession("B", "streaming");
|
|
201
|
+
sessionOrderManager.reorder(cwd, ["A", "B"]);
|
|
202
|
+
broadcasts.length = 0;
|
|
203
|
+
|
|
204
|
+
const action = applyReattachPolicy(
|
|
205
|
+
"B",
|
|
206
|
+
cwd,
|
|
207
|
+
"streaming-only",
|
|
208
|
+
{ sessionManager, sessionOrderManager, browserGateway: gateway },
|
|
209
|
+
undefined,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
expect(action).toBe("moveToFront");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("no-ops if session has ended (defensive)", () => {
|
|
216
|
+
setupSession("A");
|
|
217
|
+
setupSession("B", "ended");
|
|
218
|
+
sessionOrderManager.reorder(cwd, ["A", "B"]);
|
|
219
|
+
broadcasts.length = 0;
|
|
220
|
+
|
|
221
|
+
const action = applyReattachPolicy("B", cwd, "always", {
|
|
222
|
+
sessionManager,
|
|
223
|
+
sessionOrderManager,
|
|
224
|
+
browserGateway: gateway,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
expect(action).toBe("preserve");
|
|
228
|
+
expect(sessionOrderManager.getOrder(cwd)).toEqual(["A", "B"]);
|
|
229
|
+
expect(broadcasts).toEqual([]);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -108,4 +108,29 @@ describe("buildOrchestratorScript", () => {
|
|
|
108
108
|
expect(script).toMatch(/const PORT = 8765/);
|
|
109
109
|
expect(script).toMatch(/port: PORT/);
|
|
110
110
|
});
|
|
111
|
+
|
|
112
|
+
// See change: fix-restart-bridge-auto-start-race.
|
|
113
|
+
describe("explicit kill of prior daemon", () => {
|
|
114
|
+
it("references the dashboard.pid file path", () => {
|
|
115
|
+
const script = buildOrchestratorScript(baseParams);
|
|
116
|
+
expect(script).toContain("dashboard.pid");
|
|
117
|
+
expect(script).toMatch(/const PID_PATH = /);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("defines a killPriorDaemon function that uses SIGTERM then SIGKILL", () => {
|
|
121
|
+
const script = buildOrchestratorScript(baseParams);
|
|
122
|
+
expect(script).toMatch(/killPriorDaemon/);
|
|
123
|
+
expect(script).toMatch(/process\.kill\(\s*pid\s*,\s*"SIGTERM"\s*\)/);
|
|
124
|
+
expect(script).toMatch(/process\.kill\(\s*pid\s*,\s*"SIGKILL"\s*\)/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("the kill step runs BEFORE the portFree poll", () => {
|
|
128
|
+
const script = buildOrchestratorScript(baseParams);
|
|
129
|
+
const killIdx = script.indexOf("await killPriorDaemon()");
|
|
130
|
+
const portFreeIdx = script.indexOf("await portFree(PORT)");
|
|
131
|
+
expect(killIdx).toBeGreaterThan(-1);
|
|
132
|
+
expect(portFreeIdx).toBeGreaterThan(-1);
|
|
133
|
+
expect(killIdx).toBeLessThan(portFreeIdx);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
111
136
|
});
|