@blackbelt-technology/pi-agent-dashboard 0.4.6 → 0.5.0
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 +339 -190
- package/README.md +31 -0
- package/docs/architecture.md +238 -23
- package/package.json +14 -4
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
- package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
- package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
- package/packages/extension/src/bridge.ts +110 -1
- package/packages/extension/src/command-handler.ts +6 -0
- package/packages/extension/src/markdown-image-inliner.ts +268 -0
- package/packages/extension/src/prompt-expander.ts +50 -2
- package/packages/extension/src/provider-register.ts +117 -0
- package/packages/extension/src/server-launcher.ts +18 -1
- package/packages/extension/src/session-sync.ts +5 -0
- package/packages/server/package.json +4 -4
- package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
- package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
- package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
- package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
- package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
- package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
- package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
- package/packages/server/src/__tests__/health-shape.test.ts +43 -0
- package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
- package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
- package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
- package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
- package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
- package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
- package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
- package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
- package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
- package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
- package/packages/server/src/bootstrap-install-from-list.ts +232 -0
- package/packages/server/src/bootstrap-state.ts +18 -0
- package/packages/server/src/browser-gateway.ts +58 -21
- package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
- package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
- package/packages/server/src/cli.ts +21 -0
- package/packages/server/src/directory-service.ts +31 -0
- package/packages/server/src/event-wiring.ts +48 -2
- package/packages/server/src/home-lock.d.ts +124 -0
- package/packages/server/src/home-lock.js +330 -0
- package/packages/server/src/home-lock.js.map +1 -0
- package/packages/server/src/idle-timer.ts +15 -1
- package/packages/server/src/pi-core-updater.ts +65 -9
- package/packages/server/src/pi-gateway.ts +6 -0
- package/packages/server/src/process-manager.ts +62 -11
- package/packages/server/src/provider-auth-handlers.ts +9 -0
- package/packages/server/src/provider-auth-storage.ts +83 -51
- package/packages/server/src/provider-catalogue-cache.ts +41 -0
- package/packages/server/src/routes/doctor-routes.ts +140 -0
- package/packages/server/src/routes/provider-auth-routes.ts +9 -0
- package/packages/server/src/routes/system-routes.ts +38 -1
- package/packages/server/src/server.ts +8 -7
- package/packages/server/src/session-bootstrap.ts +27 -12
- package/packages/server/src/session-discovery.ts +10 -3
- package/packages/server/src/session-scanner.ts +4 -2
- package/packages/server/src/spawn-failure-log.ts +130 -0
- package/packages/server/src/spawn-preflight.ts +82 -0
- package/packages/server/src/spawn-register-watchdog.ts +236 -0
- package/packages/server/src/terminal-manager.ts +12 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
- package/packages/shared/src/__tests__/config.test.ts +48 -0
- package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
- package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
- package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
- package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
- package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
- package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
- package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
- package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
- package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
- package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
- package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
- package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
- package/packages/shared/src/bootstrap-install.ts +196 -2
- package/packages/shared/src/browser-protocol.ts +112 -1
- package/packages/shared/src/config.ts +15 -0
- package/packages/shared/src/dashboard-starter.ts +33 -0
- package/packages/shared/src/doctor-core.ts +821 -0
- package/packages/shared/src/index.ts +9 -0
- package/packages/shared/src/installable-list.ts +152 -0
- package/packages/shared/src/launch-source-flag.ts +14 -0
- package/packages/shared/src/launch-source-types.ts +18 -0
- package/packages/shared/src/openspec-activity-detector.ts +25 -7
- package/packages/shared/src/platform/detached-spawn.ts +13 -2
- package/packages/shared/src/platform/managed-node-path.ts +77 -0
- package/packages/shared/src/protocol.ts +46 -2
- package/packages/shared/src/rest-api.ts +4 -0
- package/packages/shared/src/skill-block-parser.ts +115 -0
- package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
- package/packages/shared/src/tool-registry/definitions.ts +18 -5
- package/packages/shared/src/tool-registry/strategies.ts +42 -0
- package/packages/shared/src/types.ts +57 -0
|
@@ -45,6 +45,23 @@ export function resolveServerCliPath(): string {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Build the environment object passed to the spawned server process.
|
|
50
|
+
* Always stamps DASHBOARD_STARTER=Bridge so the server knows it was
|
|
51
|
+
* launched by the pi bridge extension.
|
|
52
|
+
*/
|
|
53
|
+
export function buildSpawnEnv(
|
|
54
|
+
baseEnv: NodeJS.ProcessEnv = process.env,
|
|
55
|
+
): Record<string, string> {
|
|
56
|
+
// Spread process.env (may contain undefined values); filter them out.
|
|
57
|
+
const out: Record<string, string> = {};
|
|
58
|
+
for (const [k, v] of Object.entries(baseEnv)) {
|
|
59
|
+
if (v !== undefined) out[k] = v;
|
|
60
|
+
}
|
|
61
|
+
out["DASHBOARD_STARTER"] = "Bridge";
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
48
65
|
/**
|
|
49
66
|
* Build the spawn arguments from config.
|
|
50
67
|
*/
|
|
@@ -94,7 +111,7 @@ export async function launchServer(config: DashboardConfig): Promise<LaunchResul
|
|
|
94
111
|
const r = await spawnDetached({
|
|
95
112
|
cmd: process.execPath,
|
|
96
113
|
args: ["--import", loader, entry, ...args], // ban:raw-node-import-ok: entry gated by shouldUrlWrapEntry
|
|
97
|
-
env: { ...process.env },
|
|
114
|
+
env: { ...process.env, DASHBOARD_STARTER: "Bridge" },
|
|
98
115
|
logFd,
|
|
99
116
|
});
|
|
100
117
|
|
|
@@ -8,6 +8,7 @@ import { detectSessionSource } from "./source-detector.js";
|
|
|
8
8
|
import { replayEntriesAsEvents } from "@blackbelt-technology/pi-dashboard-shared/state-replay.js";
|
|
9
9
|
import { gatherGitInfo, gatherJjInfo } from "./vcs-info.js";
|
|
10
10
|
import type { FlowInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
11
|
+
import { buildProviderCatalogue } from "./provider-register.js";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Send full state sync to the server (session_register, commands, flows, models).
|
|
@@ -72,6 +73,8 @@ export function sendStateSync(
|
|
|
72
73
|
id: m.id,
|
|
73
74
|
}));
|
|
74
75
|
bc.connection.send({ type: "models_list", sessionId: bc.sessionId, models });
|
|
76
|
+
// See change: replace-hardcoded-provider-lists.
|
|
77
|
+
bc.connection.send({ type: "providers_list", sessionId: bc.sessionId, providers: buildProviderCatalogue() });
|
|
75
78
|
} catch { /* ignore */ }
|
|
76
79
|
}
|
|
77
80
|
}
|
|
@@ -164,6 +167,8 @@ export function handleSessionChange(
|
|
|
164
167
|
id: m.id,
|
|
165
168
|
}));
|
|
166
169
|
bc.connection.send({ type: "models_list", sessionId: bc.sessionId, models });
|
|
170
|
+
// See change: replace-hardcoded-provider-lists.
|
|
171
|
+
bc.connection.send({ type: "providers_list", sessionId: bc.sessionId, providers: buildProviderCatalogue() });
|
|
167
172
|
} catch { /* ignore */ }
|
|
168
173
|
}
|
|
169
174
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blackbelt-technology/pi-dashboard-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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.
|
|
35
|
-
"@blackbelt-technology/pi-dashboard-extension": "^0.
|
|
36
|
-
"@blackbelt-technology/pi-dashboard-shared": "^0.
|
|
34
|
+
"@blackbelt-technology/dashboard-plugin-runtime": "^0.5.0",
|
|
35
|
+
"@blackbelt-technology/pi-dashboard-extension": "^0.5.0",
|
|
36
|
+
"@blackbelt-technology/pi-dashboard-shared": "^0.5.0",
|
|
37
37
|
"@fastify/compress": "^8.3.1",
|
|
38
38
|
"@fastify/cookie": "^11.0.2",
|
|
39
39
|
"@fastify/cors": "^11.0.0",
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defense-in-depth at the auto-attach rename site (event-wiring.ts).
|
|
3
|
+
*
|
|
4
|
+
* The detector (`detectOpenSpecActivity`) already rejects non-slug-shaped
|
|
5
|
+
* change names after fix-uuid-rename-bug. This file tests the second layer:
|
|
6
|
+
* even if a future detector regression returns a junk `changeName`, the
|
|
7
|
+
* auto-attach branch in `event-wiring.ts` MUST refuse to mutate session state
|
|
8
|
+
* or send `rename_session`.
|
|
9
|
+
*
|
|
10
|
+
* Approach: mock `detectOpenSpecActivity` to return a UUID-shaped result,
|
|
11
|
+
* drive a tool_execution_start event end-to-end, assert no mutation.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
14
|
+
import { WebSocket } from "ws";
|
|
15
|
+
|
|
16
|
+
vi.mock("@blackbelt-technology/pi-dashboard-shared/openspec-activity-detector.js", async (importOriginal) => {
|
|
17
|
+
const actual = await importOriginal<typeof import("@blackbelt-technology/pi-dashboard-shared/openspec-activity-detector.js")>();
|
|
18
|
+
return {
|
|
19
|
+
...actual,
|
|
20
|
+
detectOpenSpecActivity: vi.fn(() => ({
|
|
21
|
+
changeName: "019df0aa-1234-5678-9abc-def012345678",
|
|
22
|
+
isActive: true,
|
|
23
|
+
})),
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Imported AFTER vi.mock so the server picks up the mocked module.
|
|
28
|
+
const { createServer } = await import("../server.js");
|
|
29
|
+
|
|
30
|
+
async function connectSession(piPort: number, sessionId: string): Promise<WebSocket> {
|
|
31
|
+
const ws = new WebSocket(`ws://localhost:${piPort}`);
|
|
32
|
+
await new Promise<void>((resolve) => {
|
|
33
|
+
ws.on("open", () => {
|
|
34
|
+
ws.send(JSON.stringify({
|
|
35
|
+
type: "session_register",
|
|
36
|
+
sessionId,
|
|
37
|
+
cwd: "/tmp",
|
|
38
|
+
source: "cli",
|
|
39
|
+
}));
|
|
40
|
+
ws.send(JSON.stringify({ type: "replay_complete", sessionId }));
|
|
41
|
+
setTimeout(resolve, 50);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
return ws;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("Auto-attach defense-in-depth: rename site rejects non-slug changeName", () => {
|
|
48
|
+
let server: Awaited<ReturnType<typeof createServer>>;
|
|
49
|
+
let piPort: number;
|
|
50
|
+
let browserPort: number;
|
|
51
|
+
let ws: WebSocket;
|
|
52
|
+
const piMessages: any[] = [];
|
|
53
|
+
|
|
54
|
+
let testPort = 19200;
|
|
55
|
+
|
|
56
|
+
beforeEach(async () => {
|
|
57
|
+
testPort += 2;
|
|
58
|
+
browserPort = testPort;
|
|
59
|
+
piPort = testPort + 1;
|
|
60
|
+
piMessages.length = 0;
|
|
61
|
+
server = await createServer({
|
|
62
|
+
port: browserPort,
|
|
63
|
+
piPort,
|
|
64
|
+
dev: true,
|
|
65
|
+
autoShutdown: false,
|
|
66
|
+
shutdownIdleSeconds: 999,
|
|
67
|
+
tunnel: false,
|
|
68
|
+
editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
|
|
69
|
+
});
|
|
70
|
+
await server.start();
|
|
71
|
+
ws = await connectSession(piPort, "s1");
|
|
72
|
+
ws.on("message", (raw) => {
|
|
73
|
+
try { piMessages.push(JSON.parse(raw.toString())); } catch { /* ignore */ }
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(async () => {
|
|
78
|
+
ws.close();
|
|
79
|
+
await server.stop();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("does NOT mutate openspecChange / attachedProposal / name when detector returns a UUID", async () => {
|
|
83
|
+
// Any tool_execution_start triggers the (mocked) detector. Path content is
|
|
84
|
+
// irrelevant — the mock ignores its inputs and returns a UUID changeName.
|
|
85
|
+
ws.send(JSON.stringify({
|
|
86
|
+
type: "event_forward",
|
|
87
|
+
sessionId: "s1",
|
|
88
|
+
event: {
|
|
89
|
+
eventType: "tool_execution_start",
|
|
90
|
+
timestamp: Date.now(),
|
|
91
|
+
data: { toolName: "Write", args: { path: "openspec/changes/add-auth/proposal.md" } },
|
|
92
|
+
},
|
|
93
|
+
}));
|
|
94
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
95
|
+
|
|
96
|
+
const session = server.sessionManager.get("s1");
|
|
97
|
+
expect(session?.openspecChange).toBeFalsy();
|
|
98
|
+
expect(session?.attachedProposal).toBeFalsy();
|
|
99
|
+
expect(session?.name).toBeFalsy();
|
|
100
|
+
|
|
101
|
+
const renameSent = piMessages.some((m) => m.type === "rename_session");
|
|
102
|
+
expect(renameSent).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for bootstrapInstallFromList.
|
|
3
|
+
*
|
|
4
|
+
* All file I/O and install calls are injected via opts so no real
|
|
5
|
+
* filesystem or subprocesses are touched.
|
|
6
|
+
*
|
|
7
|
+
* See change: simplify-electron-bootstrap-derived-state.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
10
|
+
import { createBootstrapState } from "../bootstrap-state.js";
|
|
11
|
+
import {
|
|
12
|
+
bootstrapInstallFromList,
|
|
13
|
+
type PackageInstaller,
|
|
14
|
+
} from "../bootstrap-install-from-list.js";
|
|
15
|
+
import type { InstallablePackage, InstallableList } from "@blackbelt-technology/pi-dashboard-shared/installable-list.js";
|
|
16
|
+
|
|
17
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function makePackage(overrides: Partial<InstallablePackage> = {}): InstallablePackage {
|
|
20
|
+
return {
|
|
21
|
+
name: "test-pkg",
|
|
22
|
+
version: "1.0.0",
|
|
23
|
+
required: true,
|
|
24
|
+
kind: "npm",
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeList(packages: InstallablePackage[]): InstallableList {
|
|
30
|
+
return { version: "1", packages };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build a `bootstrapInstallFromList` opts object that bypasses all real I/O.
|
|
35
|
+
* - `listResult`: the installable list (null = file absent).
|
|
36
|
+
* - `installedNames`: package names that are already installed.
|
|
37
|
+
* - `npmInstall`/`piInstall`: injectable install fns (default: succeed).
|
|
38
|
+
*/
|
|
39
|
+
interface FakeOpts {
|
|
40
|
+
listResult: InstallableList | null;
|
|
41
|
+
installedNames?: string[];
|
|
42
|
+
npmInstall?: PackageInstaller;
|
|
43
|
+
piInstall?: PackageInstaller;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildOpts(fake: FakeOpts, extra?: object) {
|
|
47
|
+
const installedSet = new Set(fake.installedNames ?? []);
|
|
48
|
+
return {
|
|
49
|
+
configDir: "/fake/config",
|
|
50
|
+
managedDir: "/fake/managed",
|
|
51
|
+
isInstalled: (pkg: InstallablePackage) => installedSet.has(pkg.name),
|
|
52
|
+
npmInstall: fake.npmInstall ?? (async () => { /* succeed */ }),
|
|
53
|
+
piInstall: fake.piInstall ?? (async () => { /* succeed */ }),
|
|
54
|
+
// Override readInstallableList via module mock — done per test via vi.mock
|
|
55
|
+
// (see below). We instead inject listResult via a wrapping helper.
|
|
56
|
+
...extra,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// We cannot easily mock `readInstallableList` without vi.mock at module level.
|
|
61
|
+
// Instead we factor out a testable inner function and re-export it.
|
|
62
|
+
// Since we cannot easily mock the module import inside bootstrapInstallFromList,
|
|
63
|
+
// we use a different approach: inject a `_readList` seam via opts.
|
|
64
|
+
//
|
|
65
|
+
// However, the current public API doesn't expose that seam. We'll test via
|
|
66
|
+
// the observable side effects (bootstrap state + thrown errors) and fake the
|
|
67
|
+
// injectable installers. For the list itself, we monkey-patch the module.
|
|
68
|
+
//
|
|
69
|
+
// Pragmatic solution: use vi.mock to replace readInstallableList.
|
|
70
|
+
|
|
71
|
+
vi.mock("@blackbelt-technology/pi-dashboard-shared/installable-list.js", () => ({
|
|
72
|
+
readInstallableList: vi.fn(),
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
import { readInstallableList } from "@blackbelt-technology/pi-dashboard-shared/installable-list.js";
|
|
76
|
+
const mockReadList = vi.mocked(readInstallableList);
|
|
77
|
+
|
|
78
|
+
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
describe("bootstrapInstallFromList", () => {
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
vi.clearAllMocks();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── Test 1: no installable.json (Bridge/Standalone parity) ──────────────
|
|
86
|
+
|
|
87
|
+
describe("file-absent path", () => {
|
|
88
|
+
it("returns immediately without setting installable state or calling any installer", async () => {
|
|
89
|
+
mockReadList.mockResolvedValue(null);
|
|
90
|
+
const state = createBootstrapState();
|
|
91
|
+
const npmInstall = vi.fn();
|
|
92
|
+
const piInstall = vi.fn();
|
|
93
|
+
|
|
94
|
+
await bootstrapInstallFromList(state, {
|
|
95
|
+
configDir: "/fake/config",
|
|
96
|
+
managedDir: "/fake/managed",
|
|
97
|
+
npmInstall,
|
|
98
|
+
piInstall,
|
|
99
|
+
isInstalled: () => false,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// No installer calls.
|
|
103
|
+
expect(npmInstall).not.toHaveBeenCalled();
|
|
104
|
+
expect(piInstall).not.toHaveBeenCalled();
|
|
105
|
+
|
|
106
|
+
// installable field NOT set (file was absent — no tracking started).
|
|
107
|
+
expect(state.get().installable).toBeUndefined();
|
|
108
|
+
|
|
109
|
+
// Status remains ready.
|
|
110
|
+
expect(state.get().status).toBe("ready");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── Test 2: synthetic installable.json ──────────────────────────────────
|
|
115
|
+
|
|
116
|
+
describe("with installable.json present", () => {
|
|
117
|
+
it("skips already-installed npm package, installs missing required + optional, final state is correct", async () => {
|
|
118
|
+
const alreadyInstalled = makePackage({ name: "already-installed-pkg", required: false });
|
|
119
|
+
const missingRequired = makePackage({ name: "missing-required-pkg", required: true });
|
|
120
|
+
const missingOptional = makePackage({ name: "missing-optional-pkg", required: false });
|
|
121
|
+
|
|
122
|
+
mockReadList.mockResolvedValue(
|
|
123
|
+
makeList([alreadyInstalled, missingRequired, missingOptional]),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const state = createBootstrapState();
|
|
127
|
+
const npmInstall = vi.fn().mockResolvedValue(undefined);
|
|
128
|
+
|
|
129
|
+
await bootstrapInstallFromList(state, {
|
|
130
|
+
configDir: "/fake/config",
|
|
131
|
+
managedDir: "/fake/managed",
|
|
132
|
+
npmInstall,
|
|
133
|
+
piInstall: vi.fn(),
|
|
134
|
+
isInstalled: (pkg) => pkg.name === "already-installed-pkg",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Two install calls (already-installed skipped).
|
|
138
|
+
expect(npmInstall).toHaveBeenCalledTimes(2);
|
|
139
|
+
expect(npmInstall.mock.calls[0][0].name).toBe("missing-required-pkg");
|
|
140
|
+
expect(npmInstall.mock.calls[1][0].name).toBe("missing-optional-pkg");
|
|
141
|
+
|
|
142
|
+
// Final state: installed=3 (1 pre-installed + 2 freshly installed), failed=0.
|
|
143
|
+
const installable = state.get().installable;
|
|
144
|
+
expect(installable).toBeDefined();
|
|
145
|
+
expect(installable!.total).toBe(3);
|
|
146
|
+
expect(installable!.installed).toBe(3);
|
|
147
|
+
expect(installable!.failed).toHaveLength(0);
|
|
148
|
+
|
|
149
|
+
// Status remains ready (no error).
|
|
150
|
+
expect(state.get().status).toBe("ready");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("installs pi-extension packages via piInstall", async () => {
|
|
154
|
+
const pkg = makePackage({ name: "my-extension", kind: "pi-extension", required: true });
|
|
155
|
+
mockReadList.mockResolvedValue(makeList([pkg]));
|
|
156
|
+
|
|
157
|
+
const state = createBootstrapState();
|
|
158
|
+
const piInstall = vi.fn().mockResolvedValue(undefined);
|
|
159
|
+
const npmInstall = vi.fn();
|
|
160
|
+
|
|
161
|
+
await bootstrapInstallFromList(state, {
|
|
162
|
+
configDir: "/fake/config",
|
|
163
|
+
managedDir: "/fake/managed",
|
|
164
|
+
npmInstall,
|
|
165
|
+
piInstall,
|
|
166
|
+
isInstalled: () => false,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(piInstall).toHaveBeenCalledOnce();
|
|
170
|
+
expect(npmInstall).not.toHaveBeenCalled();
|
|
171
|
+
expect(piInstall.mock.calls[0][0].name).toBe("my-extension");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("optional package failure is recorded in failed[] but does not throw", async () => {
|
|
175
|
+
const optionalFail = makePackage({ name: "optional-bad", required: false });
|
|
176
|
+
mockReadList.mockResolvedValue(makeList([optionalFail]));
|
|
177
|
+
|
|
178
|
+
const state = createBootstrapState();
|
|
179
|
+
const npmInstall = vi.fn().mockRejectedValue(new Error("network error"));
|
|
180
|
+
|
|
181
|
+
await expect(
|
|
182
|
+
bootstrapInstallFromList(state, {
|
|
183
|
+
configDir: "/fake/config",
|
|
184
|
+
managedDir: "/fake/managed",
|
|
185
|
+
npmInstall,
|
|
186
|
+
piInstall: vi.fn(),
|
|
187
|
+
isInstalled: () => false,
|
|
188
|
+
}),
|
|
189
|
+
).resolves.toBeUndefined();
|
|
190
|
+
|
|
191
|
+
const installable = state.get().installable;
|
|
192
|
+
expect(installable!.failed).toEqual(["optional-bad"]);
|
|
193
|
+
expect(installable!.installed).toBe(0);
|
|
194
|
+
expect(state.get().status).toBe("ready");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("required package failure sets status=failed and throws", async () => {
|
|
198
|
+
const requiredFail = makePackage({ name: "required-bad", required: true });
|
|
199
|
+
mockReadList.mockResolvedValue(makeList([requiredFail]));
|
|
200
|
+
|
|
201
|
+
const state = createBootstrapState();
|
|
202
|
+
const npmInstall = vi.fn().mockRejectedValue(new Error("disk full"));
|
|
203
|
+
|
|
204
|
+
await expect(
|
|
205
|
+
bootstrapInstallFromList(state, {
|
|
206
|
+
configDir: "/fake/config",
|
|
207
|
+
managedDir: "/fake/managed",
|
|
208
|
+
npmInstall,
|
|
209
|
+
piInstall: vi.fn(),
|
|
210
|
+
isInstalled: () => false,
|
|
211
|
+
}),
|
|
212
|
+
).rejects.toThrow('Required package "required-bad" failed to install');
|
|
213
|
+
|
|
214
|
+
expect(state.get().status).toBe("failed");
|
|
215
|
+
expect(state.get().error?.message).toContain("required-bad");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("deprecated and defaultOff packages are skipped entirely", async () => {
|
|
219
|
+
const deprecated = makePackage({ name: "old-pkg", deprecated: true });
|
|
220
|
+
const defaultOff = makePackage({ name: "opt-pkg", defaultOff: true });
|
|
221
|
+
const normal = makePackage({ name: "normal-pkg", required: true });
|
|
222
|
+
mockReadList.mockResolvedValue(makeList([deprecated, defaultOff, normal]));
|
|
223
|
+
|
|
224
|
+
const state = createBootstrapState();
|
|
225
|
+
const npmInstall = vi.fn().mockResolvedValue(undefined);
|
|
226
|
+
|
|
227
|
+
await bootstrapInstallFromList(state, {
|
|
228
|
+
configDir: "/fake/config",
|
|
229
|
+
managedDir: "/fake/managed",
|
|
230
|
+
npmInstall,
|
|
231
|
+
piInstall: vi.fn(),
|
|
232
|
+
isInstalled: () => false,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Only "normal-pkg" is processed (total=1, not 3).
|
|
236
|
+
expect(npmInstall).toHaveBeenCalledOnce();
|
|
237
|
+
expect(state.get().installable!.total).toBe(1);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("emits progress steps during install", async () => {
|
|
241
|
+
const pkg = makePackage({ name: "tracked-pkg" });
|
|
242
|
+
mockReadList.mockResolvedValue(makeList([pkg]));
|
|
243
|
+
|
|
244
|
+
const state = createBootstrapState();
|
|
245
|
+
const progressSteps: string[] = [];
|
|
246
|
+
state.subscribe((s) => {
|
|
247
|
+
if (s.progress) progressSteps.push(s.progress.step);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const npmInstall = vi.fn().mockResolvedValue(undefined);
|
|
251
|
+
|
|
252
|
+
await bootstrapInstallFromList(state, {
|
|
253
|
+
configDir: "/fake/config",
|
|
254
|
+
managedDir: "/fake/managed",
|
|
255
|
+
npmInstall,
|
|
256
|
+
piInstall: vi.fn(),
|
|
257
|
+
isInstalled: () => false,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
expect(progressSteps).toContain("tracked-pkg");
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression suite for change: fix-stale-sessions-on-reconnect.
|
|
3
|
+
*
|
|
4
|
+
* Pin: on every browser WS connect, the gateway sends exactly one
|
|
5
|
+
* `sessions_snapshot` message containing all sessions and all non-empty
|
|
6
|
+
* per-cwd orders, AND it does NOT iterate per-session `session_added`
|
|
7
|
+
* or per-cwd `sessions_reordered` for the bootstrap.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, vi } from "vitest";
|
|
10
|
+
import { EventEmitter } from "node:events";
|
|
11
|
+
import { createBrowserGateway } from "../browser-gateway.js";
|
|
12
|
+
import { createMemorySessionManager } from "../memory-session-manager.js";
|
|
13
|
+
import { createMemoryEventStore } from "../memory-event-store.js";
|
|
14
|
+
import type { PiGateway } from "../pi-gateway.js";
|
|
15
|
+
import type { SessionOrderManager } from "../session-order-manager.js";
|
|
16
|
+
|
|
17
|
+
function makeFakeWs() {
|
|
18
|
+
const ws = new EventEmitter() as EventEmitter & {
|
|
19
|
+
send: ReturnType<typeof vi.fn>;
|
|
20
|
+
close: ReturnType<typeof vi.fn>;
|
|
21
|
+
readyState: number;
|
|
22
|
+
OPEN: number;
|
|
23
|
+
};
|
|
24
|
+
ws.send = vi.fn();
|
|
25
|
+
ws.close = vi.fn();
|
|
26
|
+
ws.readyState = 1;
|
|
27
|
+
ws.OPEN = 1;
|
|
28
|
+
return ws;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeStubPiGateway(): PiGateway {
|
|
32
|
+
return {
|
|
33
|
+
start: vi.fn(),
|
|
34
|
+
stop: vi.fn(),
|
|
35
|
+
sendToSession: vi.fn(),
|
|
36
|
+
getConnectedSessionIds: vi.fn(() => []),
|
|
37
|
+
hasSession: vi.fn(() => false),
|
|
38
|
+
onEvent: vi.fn(),
|
|
39
|
+
} as unknown as PiGateway;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeStubOrderManager(orders: Record<string, string[]>): SessionOrderManager {
|
|
43
|
+
return {
|
|
44
|
+
insert: vi.fn(),
|
|
45
|
+
remove: vi.fn(),
|
|
46
|
+
getOrder: vi.fn((cwd: string) => orders[cwd] ?? []),
|
|
47
|
+
reorder: vi.fn(),
|
|
48
|
+
getAllOrders: vi.fn(() => orders),
|
|
49
|
+
moveToFront: vi.fn(),
|
|
50
|
+
} as unknown as SessionOrderManager;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sentMessages(ws: ReturnType<typeof makeFakeWs>) {
|
|
54
|
+
return ws.send.mock.calls
|
|
55
|
+
.map((args) => {
|
|
56
|
+
try { return JSON.parse(String(args[0])); } catch { return null; }
|
|
57
|
+
})
|
|
58
|
+
.filter((m): m is Record<string, unknown> => !!m && typeof m === "object");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("browser-gateway on-connect sessions_snapshot", () => {
|
|
62
|
+
it("sends exactly one sessions_snapshot and no per-session session_added/sessions_reordered", () => {
|
|
63
|
+
const sessionManager = createMemorySessionManager();
|
|
64
|
+
sessionManager.restore({
|
|
65
|
+
id: "alive-1",
|
|
66
|
+
cwd: "/repo/a",
|
|
67
|
+
source: "tui",
|
|
68
|
+
status: "active",
|
|
69
|
+
startedAt: 1,
|
|
70
|
+
hidden: false,
|
|
71
|
+
dataUnavailable: false,
|
|
72
|
+
} as never);
|
|
73
|
+
sessionManager.restore({
|
|
74
|
+
id: "ended-1",
|
|
75
|
+
cwd: "/repo/a",
|
|
76
|
+
source: "tui",
|
|
77
|
+
status: "ended",
|
|
78
|
+
startedAt: 2,
|
|
79
|
+
endedAt: 3,
|
|
80
|
+
hidden: false,
|
|
81
|
+
dataUnavailable: true,
|
|
82
|
+
} as never);
|
|
83
|
+
|
|
84
|
+
const orders: Record<string, string[]> = {
|
|
85
|
+
"/repo/a": ["alive-1"],
|
|
86
|
+
"/repo/empty": [], // should be filtered out of snapshot.orders
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const gateway = createBrowserGateway(
|
|
90
|
+
sessionManager,
|
|
91
|
+
createMemoryEventStore(() => false),
|
|
92
|
+
makeStubPiGateway(),
|
|
93
|
+
undefined,
|
|
94
|
+
undefined,
|
|
95
|
+
makeStubOrderManager(orders),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const ws = makeFakeWs();
|
|
99
|
+
gateway.wss.emit("connection", ws, {});
|
|
100
|
+
|
|
101
|
+
const msgs = sentMessages(ws);
|
|
102
|
+
const snapshots = msgs.filter((m) => m.type === "sessions_snapshot");
|
|
103
|
+
const sessionAddeds = msgs.filter((m) => m.type === "session_added");
|
|
104
|
+
const sessionsReordereds = msgs.filter((m) => m.type === "sessions_reordered");
|
|
105
|
+
|
|
106
|
+
expect(snapshots).toHaveLength(1);
|
|
107
|
+
expect(sessionAddeds).toHaveLength(0);
|
|
108
|
+
expect(sessionsReordereds).toHaveLength(0);
|
|
109
|
+
|
|
110
|
+
const snap = snapshots[0] as { sessions: Array<{ id: string; status: string }>; orders: Record<string, string[]> };
|
|
111
|
+
const ids = snap.sessions.map((s) => s.id).sort();
|
|
112
|
+
expect(ids).toEqual(["alive-1", "ended-1"]); // alive AND ended both included
|
|
113
|
+
expect(snap.orders).toEqual({ "/repo/a": ["alive-1"] }); // empty entry filtered out
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("snapshot is sent before pinned_dirs_updated and other on-connect sends", () => {
|
|
117
|
+
const sessionManager = createMemorySessionManager();
|
|
118
|
+
const gateway = createBrowserGateway(
|
|
119
|
+
sessionManager,
|
|
120
|
+
createMemoryEventStore(() => false),
|
|
121
|
+
makeStubPiGateway(),
|
|
122
|
+
undefined,
|
|
123
|
+
undefined,
|
|
124
|
+
makeStubOrderManager({}),
|
|
125
|
+
// Stub preferencesStore so pinned_dirs_updated fires.
|
|
126
|
+
{
|
|
127
|
+
getPinnedDirectories: () => [],
|
|
128
|
+
setPinnedDirectories: () => {},
|
|
129
|
+
getSessionOrder: () => ({}),
|
|
130
|
+
setSessionOrder: () => {},
|
|
131
|
+
} as never,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const ws = makeFakeWs();
|
|
135
|
+
gateway.wss.emit("connection", ws, {});
|
|
136
|
+
|
|
137
|
+
const types = sentMessages(ws).map((m) => m.type as string);
|
|
138
|
+
const snapshotIdx = types.indexOf("sessions_snapshot");
|
|
139
|
+
const pinnedIdx = types.indexOf("pinned_dirs_updated");
|
|
140
|
+
expect(snapshotIdx).toBeGreaterThanOrEqual(0);
|
|
141
|
+
expect(pinnedIdx).toBeGreaterThan(snapshotIdx);
|
|
142
|
+
});
|
|
143
|
+
});
|