@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.
Files changed (53) hide show
  1. package/AGENTS.md +38 -33
  2. package/README.md +1 -0
  3. package/docs/architecture.md +162 -4
  4. package/package.json +4 -4
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/connection-suppress-auto-start.test.ts +97 -0
  7. package/packages/extension/src/__tests__/session-sync.test.ts +81 -1
  8. package/packages/extension/src/bridge-context.ts +10 -0
  9. package/packages/extension/src/bridge.ts +22 -0
  10. package/packages/extension/src/connection.ts +29 -0
  11. package/packages/extension/src/server-auto-start.ts +16 -0
  12. package/packages/extension/src/session-sync.ts +14 -0
  13. package/packages/server/package.json +4 -4
  14. package/packages/server/src/__tests__/cli-restart.test.ts +78 -0
  15. package/packages/server/src/__tests__/config-api.test.ts +9 -0
  16. package/packages/server/src/__tests__/is-activity-event.test.ts +78 -0
  17. package/packages/server/src/__tests__/is-unread-trigger.test.ts +164 -0
  18. package/packages/server/src/__tests__/last-activity-broadcast.test.ts +190 -0
  19. package/packages/server/src/__tests__/reattach-placement.test.ts +231 -0
  20. package/packages/server/src/__tests__/restart-helper.test.ts +25 -0
  21. package/packages/server/src/__tests__/session-order-reboot.test.ts +117 -0
  22. package/packages/server/src/__tests__/session-scanner.test.ts +31 -0
  23. package/packages/server/src/__tests__/system-routes-restart.test.ts +128 -0
  24. package/packages/server/src/__tests__/unread-persistence.test.ts +55 -0
  25. package/packages/server/src/__tests__/unread-trigger-wiring.test.ts +210 -0
  26. package/packages/server/src/__tests__/viewed-session-tracker.test.ts +93 -0
  27. package/packages/server/src/browser-gateway.ts +36 -0
  28. package/packages/server/src/cli.ts +70 -2
  29. package/packages/server/src/event-status-extraction.ts +98 -1
  30. package/packages/server/src/event-wiring.ts +70 -1
  31. package/packages/server/src/memory-session-manager.ts +34 -3
  32. package/packages/server/src/pi-gateway.ts +4 -0
  33. package/packages/server/src/reattach-placement.ts +98 -0
  34. package/packages/server/src/restart-helper.ts +41 -2
  35. package/packages/server/src/routes/system-routes.ts +25 -1
  36. package/packages/server/src/server.ts +55 -3
  37. package/packages/server/src/session-scanner.ts +19 -0
  38. package/packages/server/src/viewed-session-tracker.ts +78 -0
  39. package/packages/shared/package.json +1 -1
  40. package/packages/shared/src/__tests__/config.test.ts +59 -0
  41. package/packages/shared/src/__tests__/mdns-discovery.test.ts +48 -1
  42. package/packages/shared/src/__tests__/no-bash-on-windows.test.ts +304 -0
  43. package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +1 -1
  44. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +107 -0
  45. package/packages/shared/src/__tests__/protocol.test.ts +11 -0
  46. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +92 -0
  47. package/packages/shared/src/browser-protocol.ts +25 -0
  48. package/packages/shared/src/config.ts +41 -0
  49. package/packages/shared/src/mdns-discovery.ts +32 -1
  50. package/packages/shared/src/platform/node-spawn.ts +30 -0
  51. package/packages/shared/src/protocol.ts +30 -1
  52. package/packages/shared/src/session-meta.ts +6 -0
  53. package/packages/shared/src/types.ts +19 -0
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Tests for the auto-start suppression window driven by `server_restarting`.
3
+ * See change: fix-restart-bridge-auto-start-race.
4
+ */
5
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
6
+ import { ConnectionManager } from "../connection.js";
7
+ import { autoStartServer, type AutoStartDeps } from "../server-auto-start.js";
8
+
9
+ describe("ConnectionManager.pauseAutoStart", () => {
10
+ let cm: ConnectionManager;
11
+
12
+ beforeEach(() => {
13
+ cm = new ConnectionManager({
14
+ url: "ws://localhost:9999",
15
+ WebSocketImpl: class FakeWS { constructor() { /* never connects */ } onopen?: any; onmessage?: any; onclose?: any; onerror?: any; close() { /* no-op */ } },
16
+ watchdogTimeout: 0,
17
+ });
18
+ });
19
+
20
+ it("returns false when no pause has been issued", () => {
21
+ expect(cm.shouldSuppressAutoStart()).toBe(false);
22
+ });
23
+
24
+ it("returns true within the requested window", () => {
25
+ cm.pauseAutoStart(5000);
26
+ expect(cm.shouldSuppressAutoStart()).toBe(true);
27
+ });
28
+
29
+ it("returns false after the window expires", async () => {
30
+ vi.useFakeTimers();
31
+ cm.pauseAutoStart(100);
32
+ expect(cm.shouldSuppressAutoStart()).toBe(true);
33
+ vi.advanceTimersByTime(150);
34
+ expect(cm.shouldSuppressAutoStart()).toBe(false);
35
+ vi.useRealTimers();
36
+ });
37
+
38
+ it("ignores non-positive durations", () => {
39
+ cm.pauseAutoStart(0);
40
+ expect(cm.shouldSuppressAutoStart()).toBe(false);
41
+ cm.pauseAutoStart(-1000);
42
+ expect(cm.shouldSuppressAutoStart()).toBe(false);
43
+ });
44
+
45
+ it("only extends the window — overlapping pauses don't shrink it", () => {
46
+ vi.useFakeTimers();
47
+ cm.pauseAutoStart(10_000);
48
+ cm.pauseAutoStart(100); // shorter — must not shrink
49
+ vi.advanceTimersByTime(500);
50
+ expect(cm.shouldSuppressAutoStart()).toBe(true);
51
+ vi.useRealTimers();
52
+ });
53
+ });
54
+
55
+ describe("autoStartServer respects shouldSuppressAutoStart", () => {
56
+ function makeDeps(overrides: Partial<AutoStartDeps> = {}): AutoStartDeps {
57
+ return {
58
+ discoverDashboard: vi.fn().mockResolvedValue([]),
59
+ isDashboardRunning: vi.fn().mockResolvedValue({ running: false }),
60
+ launchServer: vi.fn().mockResolvedValue({ success: true, message: "ok" }),
61
+ notify: vi.fn(),
62
+ ...overrides,
63
+ };
64
+ }
65
+
66
+ const baseConfig = { piPort: 9999, port: 8000, autoStart: true };
67
+
68
+ it("skips launchServer while suppression is active", async () => {
69
+ const deps = makeDeps({ shouldSuppressAutoStart: () => true });
70
+ const result = await autoStartServer(baseConfig, deps);
71
+ expect(deps.launchServer).not.toHaveBeenCalled();
72
+ expect(result.server).toBeUndefined();
73
+ });
74
+
75
+ it("still runs discovery + health check when suppressed", async () => {
76
+ const deps = makeDeps({
77
+ shouldSuppressAutoStart: () => true,
78
+ isDashboardRunning: vi.fn().mockResolvedValue({ running: true }),
79
+ });
80
+ const result = await autoStartServer(baseConfig, deps);
81
+ // Health check found the orchestrator-spawned server — return it.
82
+ expect(result.server).toEqual({ host: "localhost", port: 8000, piPort: 9999 });
83
+ expect(deps.launchServer).not.toHaveBeenCalled();
84
+ });
85
+
86
+ it("calls launchServer normally when not suppressed", async () => {
87
+ const deps = makeDeps({ shouldSuppressAutoStart: () => false });
88
+ await autoStartServer(baseConfig, deps);
89
+ expect(deps.launchServer).toHaveBeenCalledTimes(1);
90
+ });
91
+
92
+ it("calls launchServer when no predicate is provided (back-compat)", async () => {
93
+ const deps = makeDeps(); // no shouldSuppressAutoStart
94
+ await autoStartServer(baseConfig, deps);
95
+ expect(deps.launchServer).toHaveBeenCalledTimes(1);
96
+ });
97
+ });
@@ -2,7 +2,7 @@
2
2
  * Tests for session-sync: sendStateSync and handleSessionSwitch.
3
3
  */
4
4
  import { describe, it, expect, vi } from "vitest";
5
- import { sendStateSync } from "../session-sync.js";
5
+ import { sendStateSync, handleSessionChange } from "../session-sync.js";
6
6
  import type { BridgeContext } from "../bridge-context.js";
7
7
 
8
8
  function createMockBridgeContext(overrides?: Partial<BridgeContext>): BridgeContext {
@@ -34,6 +34,7 @@ function createMockBridgeContext(overrides?: Partial<BridgeContext>): BridgeCont
34
34
  lastGitBranch: undefined,
35
35
  lastGitPrNumber: undefined,
36
36
  lastSessionName: undefined,
37
+ hasRegisteredOnce: false,
37
38
  ...overrides,
38
39
  // Expose sent messages for assertions
39
40
  _sent: sent,
@@ -52,4 +53,83 @@ describe("sendStateSync", () => {
52
53
  expect(typeof registerMsg.pid).toBe("number");
53
54
  expect(registerMsg.pid).toBeGreaterThan(0);
54
55
  });
56
+
57
+ // ── reattach-move-to-front ──
58
+
59
+ it("first sendStateSync after boot tags registerReason: spawn", () => {
60
+ const bc = createMockBridgeContext();
61
+ expect(bc.hasRegisteredOnce).toBe(false);
62
+
63
+ sendStateSync(bc, () => []);
64
+
65
+ const sent = (bc as any)._sent;
66
+ const registerMsg = sent.find((m: any) => m.type === "session_register");
67
+ expect(registerMsg.registerReason).toBe("spawn");
68
+ expect(bc.hasRegisteredOnce).toBe(true);
69
+ });
70
+
71
+ it("second sendStateSync (reconnect) tags registerReason: reattach", () => {
72
+ const bc = createMockBridgeContext();
73
+
74
+ sendStateSync(bc, () => []);
75
+ // Clear sent, simulate reconnect
76
+ (bc as any)._sent.length = 0;
77
+ sendStateSync(bc, () => []);
78
+
79
+ const sent = (bc as any)._sent;
80
+ const registerMsg = sent.find((m: any) => m.type === "session_register");
81
+ expect(registerMsg.registerReason).toBe("reattach");
82
+ expect(bc.hasRegisteredOnce).toBe(true);
83
+ });
84
+
85
+ it("hasRegisteredOnce flips exactly once and stays true", () => {
86
+ const bc = createMockBridgeContext();
87
+
88
+ sendStateSync(bc, () => []);
89
+ expect(bc.hasRegisteredOnce).toBe(true);
90
+
91
+ sendStateSync(bc, () => []);
92
+ expect(bc.hasRegisteredOnce).toBe(true);
93
+
94
+ sendStateSync(bc, () => []);
95
+ expect(bc.hasRegisteredOnce).toBe(true);
96
+ });
97
+
98
+ it("third+ sendStateSync continues to tag reattach", () => {
99
+ const bc = createMockBridgeContext();
100
+
101
+ sendStateSync(bc, () => []);
102
+ sendStateSync(bc, () => []);
103
+ (bc as any)._sent.length = 0;
104
+ sendStateSync(bc, () => []);
105
+
106
+ const sent = (bc as any)._sent;
107
+ const registerMsg = sent.find((m: any) => m.type === "session_register");
108
+ expect(registerMsg.registerReason).toBe("reattach");
109
+ });
110
+ });
111
+
112
+ describe("handleSessionChange", () => {
113
+ it("always tags registerReason: spawn even after reattach", () => {
114
+ const bc = createMockBridgeContext({ hasRegisteredOnce: true } as any);
115
+
116
+ const ctx = {
117
+ cwd: "/proj",
118
+ sessionManager: {
119
+ getSessionId: () => "sess-new",
120
+ getSessionFile: () => "/path/new.json",
121
+ getSessionDir: () => "/path",
122
+ getBranch: () => [],
123
+ getEntries: () => [],
124
+ },
125
+ };
126
+
127
+ handleSessionChange(bc, ctx as any, () => []);
128
+
129
+ const sent = (bc as any)._sent;
130
+ const registerMsg = sent.find((m: any) => m.type === "session_register");
131
+ expect(registerMsg).toBeDefined();
132
+ expect(registerMsg.sessionId).toBe("sess-new");
133
+ expect(registerMsg.registerReason).toBe("spawn");
134
+ });
55
135
  });
@@ -21,6 +21,16 @@ export interface BridgeContext {
21
21
  lastGitBranch: string | undefined;
22
22
  lastGitPrNumber: number | undefined;
23
23
  lastSessionName: string | undefined;
24
+ /**
25
+ * `false` until the very first `sendStateSync` after the bridge
26
+ * process boots; `true` for the rest of the process lifetime.
27
+ * Drives `registerReason` on `session_register` so the server can
28
+ * distinguish initial spawn vs. dashboard-restart reattach.
29
+ * `handleSessionChange` (new/fork/resume) ignores this flag and
30
+ * always tags `"spawn"` because it mints a fresh sessionId.
31
+ * See change: reattach-move-to-front.
32
+ */
33
+ hasRegisteredOnce: boolean;
24
34
  }
25
35
 
26
36
  // Commands that the dashboard handles natively with superior UX.
@@ -186,6 +186,7 @@ function initBridge(pi: ExtensionAPI) {
186
186
  let cachedCtx: any | undefined = prev.ctx;
187
187
  let lastModel: string | undefined;
188
188
  let lastThinkingLevel: string | undefined;
189
+ let hasRegisteredOnce = false; // see change: reattach-move-to-front
189
190
  let promptBus: PromptBus | undefined;
190
191
 
191
192
  // ── Per-message entry id tracking (for fix-per-message-fork) ──
@@ -288,6 +289,20 @@ function initBridge(pi: ExtensionAPI) {
288
289
  handleUiManagement(uiModulesBridgeCtx, msg as any);
289
290
  return;
290
291
  }
292
+ // Server announced a deliberate restart/shutdown. Pause the auto-start
293
+ // spawn step in `server-auto-start.ts` for `quiesceMs` so we don't
294
+ // race the orchestrator that's about to bring up the replacement.
295
+ // Discovery + reconnection still run via the normal backoff path.
296
+ // See change: fix-restart-bridge-auto-start-race.
297
+ if ((msg as any).type === "server_restarting") {
298
+ const reason = (msg as any).reason;
299
+ const quiesceMs = (msg as any).quiesceMs;
300
+ if (typeof quiesceMs === "number" && quiesceMs > 0) {
301
+ connection.pauseAutoStart(quiesceMs);
302
+ console.log(`[dashboard] server announced restart (reason=${reason} quiesceMs=${quiesceMs})`);
303
+ }
304
+ return;
305
+ }
291
306
  // Legacy extension_ui_response removed — now handled by prompt_response → promptBus.respond()
292
307
  // Reload auth credentials when dashboard notifies of changes
293
308
  if (msg.type === "credentials_updated") {
@@ -596,6 +611,7 @@ function initBridge(pi: ExtensionAPI) {
596
611
  lastModel, lastThinkingLevel,
597
612
  lastSessionFile, lastSessionDir, lastFirstMessage,
598
613
  lastGitBranch, lastGitPrNumber, lastSessionName,
614
+ hasRegisteredOnce,
599
615
  };
600
616
  }
601
617
  /** Sync BridgeContext mutations back to local variables */
@@ -612,6 +628,7 @@ function initBridge(pi: ExtensionAPI) {
612
628
  lastGitBranch = bc.lastGitBranch;
613
629
  lastGitPrNumber = bc.lastGitPrNumber;
614
630
  lastSessionName = bc.lastSessionName;
631
+ hasRegisteredOnce = bc.hasRegisteredOnce;
615
632
  }
616
633
 
617
634
  // Local wrappers that sync bc around extracted module calls
@@ -1195,6 +1212,11 @@ function initBridge(pi: ExtensionAPI) {
1195
1212
  onLaunchEnd: () => {
1196
1213
  stopSpinner();
1197
1214
  },
1215
+ // Honor the server's `server_restarting` quiesce window. While a
1216
+ // deliberate restart/shutdown is in flight, skip the spawn step so we
1217
+ // don't race the orchestrator. Discovery + reconnection still run.
1218
+ // See change: fix-restart-bridge-auto-start-race.
1219
+ shouldSuppressAutoStart: () => connection.shouldSuppressAutoStart(),
1198
1220
  }).then((result) => {
1199
1221
  stopSpinner(); // safety net — covers onLaunchEnd not firing
1200
1222
  if (result.server && result.server.piPort !== config.piPort) {
@@ -35,6 +35,16 @@ export class ConnectionManager {
35
35
  private watchdogTimer: ReturnType<typeof setInterval> | null = null;
36
36
  private watchdogTimeout: number;
37
37
 
38
+ /**
39
+ * Auto-start suppression deadline (epoch ms). When the server announces
40
+ * a deliberate restart/shutdown via `server_restarting`, the bridge sets
41
+ * this to `Date.now() + quiesceMs` so the spawn step in `autoStartServer`
42
+ * is skipped while the orchestrator does its work. Discovery + reconnect
43
+ * are NOT suppressed.
44
+ * See change: fix-restart-bridge-auto-start-race.
45
+ */
46
+ private suppressUntil = 0;
47
+
38
48
  constructor(options: ConnectionManagerOptions) {
39
49
  this.url = options.url;
40
50
  this.WS = options.WebSocketImpl ?? (globalThis as any).WebSocket;
@@ -90,6 +100,25 @@ export class ConnectionManager {
90
100
  return this.ws?.readyState === 1;
91
101
  }
92
102
 
103
+ /**
104
+ * Pause auto-start spawn for `ms` milliseconds. Idempotent: only extends
105
+ * the suppression window, never shortens it. See change:
106
+ * fix-restart-bridge-auto-start-race.
107
+ */
108
+ pauseAutoStart(ms: number): void {
109
+ if (!Number.isFinite(ms) || ms <= 0) return;
110
+ const next = Date.now() + ms;
111
+ if (next > this.suppressUntil) this.suppressUntil = next;
112
+ }
113
+
114
+ /**
115
+ * Returns true while the auto-start spawn step should be suppressed.
116
+ * See change: fix-restart-bridge-auto-start-race.
117
+ */
118
+ shouldSuppressAutoStart(): boolean {
119
+ return Date.now() < this.suppressUntil;
120
+ }
121
+
93
122
  /**
94
123
  * Update the WebSocket URL and reconnect.
95
124
  * Used when mDNS discovers the server on a different address/port.
@@ -32,6 +32,14 @@ export interface AutoStartDeps {
32
32
  * Passes the final success state so the caller can clear spinners.
33
33
  */
34
34
  onLaunchEnd?: (success: boolean) => void;
35
+ /**
36
+ * Optional predicate. When it returns true, the auto-start spawn step
37
+ * (step 3 below) is skipped — mDNS discovery + health check still run,
38
+ * so the bridge will pick up the orchestrator-spawned replacement as
39
+ * soon as it advertises. Used by the bridge to honor `server_restarting`
40
+ * bursts. See change: fix-restart-bridge-auto-start-race.
41
+ */
42
+ shouldSuppressAutoStart?: () => boolean;
35
43
  }
36
44
 
37
45
  export interface AutoStartResult {
@@ -73,6 +81,14 @@ export async function autoStartServer(
73
81
  return {};
74
82
  }
75
83
 
84
+ // Suppress the spawn step while a deliberate restart/shutdown is in
85
+ // flight. Discovery + health check above already ran, so if the
86
+ // orchestrator has finished bringing up the replacement we already
87
+ // returned. See change: fix-restart-bridge-auto-start-race.
88
+ if (deps.shouldSuppressAutoStart?.()) {
89
+ return {};
90
+ }
91
+
76
92
  // 3. Auto-start server
77
93
  deps.onLaunchStart?.();
78
94
  const result = await deps.launchServer(config);
@@ -33,6 +33,13 @@ export function sendStateSync(
33
33
  if (entries) eventCount = entries.length;
34
34
  } catch { /* ignore */ }
35
35
 
36
+ // Tag the very first sendStateSync after process boot as "spawn";
37
+ // every subsequent invocation (driven by WebSocket reconnect after a
38
+ // dashboard restart) is a "reattach". Server applies the configured
39
+ // `reattachPlacement` policy on "reattach".
40
+ // See change: reattach-move-to-front.
41
+ const registerReason: "spawn" | "reattach" = bc.hasRegisteredOnce ? "reattach" : "spawn";
42
+
36
43
  bc.connection.send({
37
44
  type: "session_register",
38
45
  sessionId: bc.sessionId,
@@ -46,8 +53,11 @@ export function sendStateSync(
46
53
  firstMessage,
47
54
  eventCount,
48
55
  pid: process.pid,
56
+ registerReason,
49
57
  });
50
58
 
59
+ bc.hasRegisteredOnce = true;
60
+
51
61
  const commands = filterHiddenCommands(bc.pi.getCommands());
52
62
  bc.connection.send({ type: "commands_list", sessionId: bc.sessionId, commands });
53
63
 
@@ -111,6 +121,9 @@ export function handleSessionChange(
111
121
  if (entries) eventCount = entries.length;
112
122
  } catch { /* ignore */ }
113
123
 
124
+ // handleSessionChange always mints a fresh sessionId (new/fork/resume),
125
+ // so registerReason is unconditionally "spawn" — even after the bridge
126
+ // has previously reattached. See change: reattach-move-to-front.
114
127
  bc.connection.send({
115
128
  type: "session_register",
116
129
  sessionId: bc.sessionId,
@@ -124,6 +137,7 @@ export function handleSessionChange(
124
137
  firstMessage,
125
138
  eventCount,
126
139
  pid: process.pid,
140
+ registerReason: "spawn",
127
141
  });
128
142
 
129
143
  replaySessionEntries(bc);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-server",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "Dashboard server for monitoring and interacting with pi agent sessions",
5
5
  "type": "module",
6
6
  "repository": {
@@ -31,9 +31,9 @@
31
31
  "postinstall": "node scripts/fix-pty-permissions.cjs"
32
32
  },
33
33
  "dependencies": {
34
- "@blackbelt-technology/dashboard-plugin-runtime": "^0.4.4",
35
- "@blackbelt-technology/pi-dashboard-extension": "^0.4.4",
36
- "@blackbelt-technology/pi-dashboard-shared": "^0.4.4",
34
+ "@blackbelt-technology/dashboard-plugin-runtime": "^0.4.5",
35
+ "@blackbelt-technology/pi-dashboard-extension": "^0.4.5",
36
+ "@blackbelt-technology/pi-dashboard-shared": "^0.4.5",
37
37
  "@fastify/compress": "^8.3.1",
38
38
  "@fastify/cookie": "^11.0.2",
39
39
  "@fastify/cors": "^11.0.0",
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Tests for `cmdRestart` — `pi-dashboard restart` delegates to `/api/restart`
3
+ * when the dashboard is up, falls back to local stop/start when it is not.
4
+ * See change: fix-restart-bridge-auto-start-race.
5
+ */
6
+ import { describe, it, expect, vi } from "vitest";
7
+ import { cmdRestart } from "../cli.js";
8
+ import type { ServerConfig } from "../server.js";
9
+
10
+ function makeConfig(overrides: Partial<ServerConfig> = {}): ServerConfig {
11
+ return {
12
+ port: 8000,
13
+ piPort: 9999,
14
+ dev: false,
15
+ autoShutdown: false,
16
+ shutdownIdleSeconds: 0,
17
+ tunnel: false,
18
+ maxWsBufferBytes: 0,
19
+ ...overrides,
20
+ } as ServerConfig;
21
+ }
22
+
23
+ describe("cmdRestart", () => {
24
+ it("delegates to /api/restart when dashboard is running", async () => {
25
+ const fetchImpl = vi.fn(async () => ({ ok: true, status: 200, text: async () => "" })) as unknown as typeof fetch;
26
+ const cmdStopImpl = vi.fn(async () => {});
27
+ const cmdStartImpl = vi.fn(async () => {});
28
+ const isDashboardRunning = vi.fn(async () => ({ running: true }));
29
+
30
+ await cmdRestart(makeConfig({ dev: true }), { isDashboardRunning, fetchImpl, cmdStopImpl, cmdStartImpl });
31
+
32
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
33
+ const [url, init] = (fetchImpl as any).mock.calls[0];
34
+ expect(url).toBe("http://localhost:8000/api/restart");
35
+ expect(init.method).toBe("POST");
36
+ expect(JSON.parse(init.body)).toEqual({ dev: true });
37
+ expect(cmdStopImpl).not.toHaveBeenCalled();
38
+ expect(cmdStartImpl).not.toHaveBeenCalled();
39
+ });
40
+
41
+ it("falls back to cmdStop + cmdStart when dashboard is NOT running", async () => {
42
+ const fetchImpl = vi.fn() as unknown as typeof fetch;
43
+ const cmdStopImpl = vi.fn(async () => {});
44
+ const cmdStartImpl = vi.fn(async () => {});
45
+ const isDashboardRunning = vi.fn(async () => ({ running: false }));
46
+
47
+ await cmdRestart(makeConfig(), { isDashboardRunning, fetchImpl, cmdStopImpl, cmdStartImpl });
48
+
49
+ expect(fetchImpl).not.toHaveBeenCalled();
50
+ expect(cmdStopImpl).toHaveBeenCalledTimes(1);
51
+ expect(cmdStartImpl).toHaveBeenCalledTimes(1);
52
+ });
53
+
54
+ it("falls back to local stop/start when /api/restart returns non-2xx", async () => {
55
+ const fetchImpl = vi.fn(async () => ({ ok: false, status: 500, text: async () => "boom" })) as unknown as typeof fetch;
56
+ const cmdStopImpl = vi.fn(async () => {});
57
+ const cmdStartImpl = vi.fn(async () => {});
58
+ const isDashboardRunning = vi.fn(async () => ({ running: true }));
59
+
60
+ await cmdRestart(makeConfig(), { isDashboardRunning, fetchImpl, cmdStopImpl, cmdStartImpl });
61
+
62
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
63
+ expect(cmdStopImpl).toHaveBeenCalledTimes(1);
64
+ expect(cmdStartImpl).toHaveBeenCalledTimes(1);
65
+ });
66
+
67
+ it("falls back to local stop/start when fetch throws", async () => {
68
+ const fetchImpl = vi.fn(async () => { throw new Error("ECONNREFUSED"); }) as unknown as typeof fetch;
69
+ const cmdStopImpl = vi.fn(async () => {});
70
+ const cmdStartImpl = vi.fn(async () => {});
71
+ const isDashboardRunning = vi.fn(async () => ({ running: true }));
72
+
73
+ await cmdRestart(makeConfig(), { isDashboardRunning, fetchImpl, cmdStopImpl, cmdStartImpl });
74
+
75
+ expect(cmdStopImpl).toHaveBeenCalledTimes(1);
76
+ expect(cmdStartImpl).toHaveBeenCalledTimes(1);
77
+ });
78
+ });
@@ -168,5 +168,14 @@ describe("config-api", () => {
168
168
  const written = JSON.parse(fs.readFileSync(configFile, "utf-8"));
169
169
  expect(written.auth.bypassUrls).toEqual(["/webhooks/", "/metrics"]);
170
170
  });
171
+
172
+ it("should persist reattachPlacement (change: reattach-move-to-front)", () => {
173
+ fs.writeFileSync(configFile, JSON.stringify({ port: 8000 }));
174
+ const result = writeConfigPartial({ reattachPlacement: "preserve" });
175
+ expect(result.success).toBe(true);
176
+ const written = JSON.parse(fs.readFileSync(configFile, "utf-8"));
177
+ expect(written.reattachPlacement).toBe("preserve");
178
+ expect(written.port).toBe(8000); // existing fields preserved
179
+ });
171
180
  });
172
181
  });
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { isActivityEvent } from "../event-status-extraction.js";
3
+
4
+ /**
5
+ * Activity-event allowlist for `session.lastActivityAt` stamping.
6
+ * See change: session-card-last-activity-badge.
7
+ */
8
+ describe("isActivityEvent", () => {
9
+ describe("included (user-or-agent action)", () => {
10
+ const included = [
11
+ "prompt_send",
12
+ "message_start",
13
+ "message_end",
14
+ "turn_end",
15
+ "tool_execution_start",
16
+ "tool_execution_end",
17
+ "agent_start",
18
+ "agent_end",
19
+ "bash_output",
20
+ "flow_started",
21
+ "flow_complete",
22
+ "flow_agent_started",
23
+ "flow_agent_complete",
24
+ "architect_started",
25
+ "architect_complete",
26
+ "architect_cancelled",
27
+ ];
28
+
29
+ for (const t of included) {
30
+ it(`returns true for "${t}"`, () => {
31
+ expect(isActivityEvent(t)).toBe(true);
32
+ });
33
+ }
34
+ });
35
+
36
+ describe("excluded (plumbing / noise / UI state)", () => {
37
+ const excluded = [
38
+ // Pure heartbeat / metrics
39
+ "process_metrics",
40
+ "heartbeat",
41
+ // Stats roll-ups (covered by turn_end)
42
+ "stats_update",
43
+ // Selection / config
44
+ "model_select",
45
+ // Git polling
46
+ "git_info_update",
47
+ // OpenSpec polling
48
+ "openspec_update",
49
+ // Extension UI plumbing
50
+ "ui_modules_list",
51
+ "ui_data_list",
52
+ "ext_ui_decorator",
53
+ // Command UI noise
54
+ "command_feedback",
55
+ // Internal entry tracking
56
+ "entry_persisted",
57
+ // Lifecycle (not event_forward types, but defensive)
58
+ "session_register",
59
+ "session_unregister",
60
+ ];
61
+
62
+ for (const t of excluded) {
63
+ it(`returns false for "${t}"`, () => {
64
+ expect(isActivityEvent(t)).toBe(false);
65
+ });
66
+ }
67
+ });
68
+
69
+ describe("unknown event types", () => {
70
+ it("returns false for an unknown type", () => {
71
+ expect(isActivityEvent("definitely_not_a_real_event")).toBe(false);
72
+ });
73
+
74
+ it("returns false for empty string", () => {
75
+ expect(isActivityEvent("")).toBe(false);
76
+ });
77
+ });
78
+ });