@blackbelt-technology/pi-agent-dashboard 0.5.0 → 0.5.2
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 +26 -5
- package/README.md +49 -7
- package/docs/architecture.md +129 -1
- package/package.json +15 -15
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
- package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +78 -8
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
- package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
- package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
- package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
- package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
- package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
- package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
- package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
- package/packages/extension/src/ask-user-tool.ts +1 -1
- package/packages/extension/src/bridge-context.ts +68 -4
- package/packages/extension/src/bridge.ts +79 -11
- package/packages/extension/src/command-handler.ts +95 -15
- package/packages/extension/src/flow-event-wiring.ts +1 -1
- package/packages/extension/src/multiselect-list.ts +1 -1
- package/packages/extension/src/pi-env.d.ts +16 -9
- package/packages/extension/src/prompt-expander.ts +74 -63
- package/packages/extension/src/provider-register.ts +16 -9
- package/packages/extension/src/retry-tracker.ts +123 -0
- package/packages/extension/src/server-launcher.ts +31 -70
- package/packages/extension/src/session-sync.ts +10 -1
- package/packages/extension/src/slash-dispatch.ts +123 -0
- package/packages/extension/src/usage-limit-orderer.ts +76 -0
- package/packages/server/bin/pi-dashboard.mjs +84 -0
- package/packages/server/package.json +8 -7
- package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
- package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
- package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
- package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +16 -4
- package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service.test.ts +2 -2
- package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
- package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +68 -1
- package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
- package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +316 -0
- package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
- package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
- package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
- package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
- package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
- package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
- package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
- package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
- package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
- package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
- package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
- package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
- package/packages/server/src/__tests__/package-routes.test.ts +1 -1
- package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
- package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
- package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
- package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +62 -3
- package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
- package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
- package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
- package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +13 -23
- package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +3 -3
- package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +84 -0
- package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
- package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
- package/packages/server/src/auth-plugin.ts +3 -0
- package/packages/server/src/bootstrap-state.ts +10 -0
- package/packages/server/src/browser-gateway.ts +27 -10
- package/packages/server/src/browser-handlers/handler-context.ts +9 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +128 -19
- package/packages/server/src/changelog-fs.ts +167 -0
- package/packages/server/src/changelog-parser.ts +321 -0
- package/packages/server/src/changelog-remote.ts +134 -0
- package/packages/server/src/cli.ts +62 -82
- package/packages/server/src/config-api.ts +14 -2
- package/packages/server/src/directory-service.ts +106 -4
- package/packages/server/src/event-wiring.ts +90 -6
- package/packages/server/src/headless-pid-registry.ts +344 -37
- package/packages/server/src/legacy-pi-cleanup.ts +151 -0
- package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
- package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
- package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
- package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
- package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
- package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
- package/packages/server/src/model-proxy/api-key-store.ts +87 -0
- package/packages/server/src/model-proxy/auth-gate.ts +116 -0
- package/packages/server/src/model-proxy/concurrency.ts +76 -0
- package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
- package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
- package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
- package/packages/server/src/model-proxy/convert/index.ts +8 -0
- package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
- package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
- package/packages/server/src/model-proxy/convert/types.ts +70 -0
- package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
- package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
- package/packages/server/src/model-proxy/internal-registry.ts +157 -0
- package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
- package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
- package/packages/server/src/model-proxy/request-log.ts +53 -0
- package/packages/server/src/model-proxy/streamer.ts +59 -0
- package/packages/server/src/openspec-group-store.ts +490 -0
- package/packages/server/src/pending-client-correlations.ts +73 -0
- package/packages/server/src/pending-fork-registry.ts +24 -12
- package/packages/server/src/pi-core-checker.ts +77 -17
- package/packages/server/src/pi-core-updater.ts +16 -6
- package/packages/server/src/pi-dev-version-check.ts +145 -0
- package/packages/server/src/pi-gateway.ts +4 -0
- package/packages/server/src/pi-version-skew.ts +12 -4
- package/packages/server/src/process-manager.ts +182 -11
- package/packages/server/src/provider-auth-storage.ts +29 -47
- package/packages/server/src/provider-catalogue-cache.ts +24 -18
- package/packages/server/src/restart-helper.ts +17 -16
- package/packages/server/src/routes/bootstrap-routes.ts +37 -0
- package/packages/server/src/routes/jj-routes.ts +3 -0
- package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
- package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
- package/packages/server/src/routes/model-proxy-routes.ts +330 -0
- package/packages/server/src/routes/openspec-group-routes.ts +231 -0
- package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
- package/packages/server/src/routes/pi-core-routes.ts +1 -1
- package/packages/server/src/routes/provider-auth-routes.ts +8 -1
- package/packages/server/src/routes/provider-routes.ts +28 -5
- package/packages/server/src/routes/system-routes.ts +44 -2
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
- package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
- package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
- package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
- package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
- package/packages/server/src/server.ts +254 -60
- package/packages/server/src/session-api.ts +63 -4
- package/packages/server/src/session-discovery.ts +1 -1
- package/packages/server/src/session-file-reader.ts +1 -1
- package/packages/server/src/spawn-register-watchdog.ts +62 -7
- package/packages/server/src/spawn-token.ts +20 -0
- package/packages/server/src/tunnel-watchdog.ts +230 -0
- package/packages/server/src/tunnel.ts +5 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +24 -17
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +5 -3
- package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
- package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
- package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
- package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
- package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
- package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
- package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
- package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
- package/packages/shared/src/bootstrap-install.ts +1 -1
- package/packages/shared/src/browser-protocol.ts +70 -0
- package/packages/shared/src/changelog-types.ts +111 -0
- package/packages/shared/src/config.ts +172 -2
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
- package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
- package/packages/shared/src/platform/binary-lookup.ts +204 -0
- package/packages/shared/src/platform/node-spawn.ts +71 -26
- package/packages/shared/src/protocol.ts +27 -1
- package/packages/shared/src/recommended-extensions.ts +18 -0
- package/packages/shared/src/rest-api.ts +219 -1
- package/packages/shared/src/server-launcher.ts +277 -0
- package/packages/shared/src/skill-block-parser.ts +1 -1
- package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
- package/packages/shared/src/tool-registry/definitions.ts +15 -3
- package/packages/shared/src/types.ts +62 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -53
- package/packages/shared/src/resolve-jiti.ts +0 -102
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeper integration tests.
|
|
3
|
+
*
|
|
4
|
+
* Spawns `node packages/server/src/rpc-keeper/keeper.cjs <sessionId>` as a
|
|
5
|
+
* real subprocess (NOT via jiti / tsx — the whole point is that keeper.cjs
|
|
6
|
+
* runs under bare node). A `pi` PATH shim invokes a `mock-pi.cjs` fixture
|
|
7
|
+
* so we exercise the spawn path without needing a real pi binary.
|
|
8
|
+
*
|
|
9
|
+
* Note re tasks.md 3.1: spec says ".test.cjs". We write the driver in TS
|
|
10
|
+
* (existing vitest glob is `*.test.ts`); the BINARY-under-test is still
|
|
11
|
+
* pure CJS. The CJS contract is what we verify — the test runner is irrelevant.
|
|
12
|
+
*
|
|
13
|
+
* Tasks covered: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7.
|
|
14
|
+
*/
|
|
15
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
16
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, unlinkSync, rmSync } from "node:fs";
|
|
17
|
+
import net from "node:net";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
20
|
+
|
|
21
|
+
const KEEPER_PATH = path.resolve(__dirname, "..", "keeper.cjs");
|
|
22
|
+
const FIXTURES_DIR = path.resolve(__dirname, "fixtures");
|
|
23
|
+
const SHIM_DIR = FIXTURES_DIR;
|
|
24
|
+
|
|
25
|
+
// macOS UDS sun_path is 104 bytes. The root `npm test` HOME under
|
|
26
|
+
// /var/folders/.../pi-test-XXXXXX is ~73 chars before any further nesting,
|
|
27
|
+
// which exceeds the limit once we append `.pi/dashboard/sessions/<uuid>.rpc.sock`.
|
|
28
|
+
// Each test mints its OWN short HOME under /tmp/p... (≤ 12 chars), passed to
|
|
29
|
+
// the keeper subprocess via env. The npm-test HOME isolation tripwire is
|
|
30
|
+
// unaffected — we only override HOME for the spawned child, not the test
|
|
31
|
+
// runner itself. We still create the per-test HOME under /tmp (not the npm-test
|
|
32
|
+
// HOME) because /tmp is short, AND we keep the test isolated from production paths.
|
|
33
|
+
function sessionsDirIn(home: string): string {
|
|
34
|
+
return path.join(home, ".pi", "dashboard", "sessions");
|
|
35
|
+
}
|
|
36
|
+
function sockPathIn(home: string, sid: string): string {
|
|
37
|
+
return process.platform === "win32"
|
|
38
|
+
? `\\\\.\\pipe\\pi-rpc-${sid}`
|
|
39
|
+
: path.join(sessionsDirIn(home), `${sid}.rpc.sock`);
|
|
40
|
+
}
|
|
41
|
+
function pidPathIn(home: string, sid: string): string {
|
|
42
|
+
return process.platform === "win32"
|
|
43
|
+
? path.join(sessionsDirIn(home), `pi-rpc-${sid}.pid`)
|
|
44
|
+
: `${sockPathIn(home, sid)}.pid`;
|
|
45
|
+
}
|
|
46
|
+
function keeperLogIn(home: string, sid: string): string {
|
|
47
|
+
return path.join(sessionsDirIn(home), `keeper-${sid}.log`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function makeSessionId(): string {
|
|
51
|
+
// Short ID to keep total UDS path comfortably under 104 bytes even on
|
|
52
|
+
// edge-case test environments.
|
|
53
|
+
return `t${Math.floor(Math.random() * 1e9).toString(36)}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makeShortHome(): string {
|
|
57
|
+
// /tmp resolves to /private/tmp on macOS but Node uses the path as-given
|
|
58
|
+
// for UDS bind; either resolved form fits well under 104 bytes.
|
|
59
|
+
// mkdtempSync('/tmp/p') yields '/tmp/pXXXXXX' (≈12 chars).
|
|
60
|
+
return mkdtempSync(path.join("/tmp", "p"));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface SpawnedKeeper {
|
|
64
|
+
child: ChildProcess;
|
|
65
|
+
sessionId: string;
|
|
66
|
+
home: string;
|
|
67
|
+
mockPiLog: string;
|
|
68
|
+
exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Convenience accessors that route through the keeper's own home.
|
|
72
|
+
function sockPathFor(s: SpawnedKeeper): string { return sockPathIn(s.home, s.sessionId); }
|
|
73
|
+
function pidPathFor(s: SpawnedKeeper): string { return pidPathIn(s.home, s.sessionId); }
|
|
74
|
+
function keeperLogFor(s: SpawnedKeeper): string { return keeperLogIn(s.home, s.sessionId); }
|
|
75
|
+
|
|
76
|
+
interface SpawnKeeperOpts {
|
|
77
|
+
/** "normal" (default) or "crash" (mock-pi exits 1 immediately) */
|
|
78
|
+
mode?: "normal" | "crash";
|
|
79
|
+
/** Override sessionId; otherwise auto-generated */
|
|
80
|
+
sessionId?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface SpawnKeeperOptsExt extends SpawnKeeperOpts {
|
|
84
|
+
/** Override HOME (default: short tmp dir under /tmp/p...). */
|
|
85
|
+
home?: string;
|
|
86
|
+
/** If true, do NOT pre-create sessionsDir (tests stale-socket scenarios). */
|
|
87
|
+
skipMkdir?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function spawnKeeper(opts: SpawnKeeperOptsExt = {}): Promise<SpawnedKeeper> {
|
|
91
|
+
const sessionId = opts.sessionId ?? makeSessionId();
|
|
92
|
+
const home = opts.home ?? makeShortHome();
|
|
93
|
+
if (!opts.skipMkdir) mkdirSync(sessionsDirIn(home), { recursive: true });
|
|
94
|
+
|
|
95
|
+
const mockPiLog = path.join(sessionsDirIn(home), `mock-pi-${sessionId}.log`);
|
|
96
|
+
|
|
97
|
+
// PATH shim: prepend a dir containing a `pi` script that execs our mock.
|
|
98
|
+
const tmpBin = path.join(home, "bin");
|
|
99
|
+
mkdirSync(tmpBin, { recursive: true });
|
|
100
|
+
const piShimDest = path.join(tmpBin, "pi");
|
|
101
|
+
const shimSrc = path.join(SHIM_DIR, "mock-pi-shim.sh");
|
|
102
|
+
writeFileSync(piShimDest, readFileSync(shimSrc, "utf8"), { mode: 0o755 });
|
|
103
|
+
|
|
104
|
+
const env: NodeJS.ProcessEnv = {
|
|
105
|
+
...process.env,
|
|
106
|
+
HOME: home,
|
|
107
|
+
PATH: `${tmpBin}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
108
|
+
MOCK_PI_CJS_PATH: path.join(SHIM_DIR, "mock-pi.cjs"),
|
|
109
|
+
MOCK_PI_LOG: mockPiLog,
|
|
110
|
+
MOCK_PI_MODE: opts.mode ?? "normal",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const child = spawn(process.execPath, [KEEPER_PATH, sessionId], {
|
|
114
|
+
env,
|
|
115
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Capture stderr for diagnostics on test failure.
|
|
119
|
+
child.stderr?.on("data", (b) => {
|
|
120
|
+
if (process.env.KEEPER_TEST_DEBUG) process.stderr.write(`[keeper:${sessionId}] ${b}`);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
|
|
124
|
+
(resolve) => {
|
|
125
|
+
child.once("exit", (code, signal) => resolve({ code, signal }));
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
return { child, sessionId, home, mockPiLog, exited };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function waitFor(predicate: () => boolean, timeoutMs = 3000, intervalMs = 25): Promise<void> {
|
|
133
|
+
const start = Date.now();
|
|
134
|
+
while (Date.now() - start < timeoutMs) {
|
|
135
|
+
if (predicate()) return;
|
|
136
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`waitFor timed out after ${timeoutMs}ms`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function readyKeeper(s: SpawnedKeeper): Promise<void> {
|
|
142
|
+
// "Ready" = (a) socket bound, (b) pid sidecar written, (c) past 300ms
|
|
143
|
+
// crash window AND keeper still running.
|
|
144
|
+
await waitFor(() => existsSync(pidPathFor(s)));
|
|
145
|
+
if (process.platform !== "win32") {
|
|
146
|
+
await waitFor(() => existsSync(sockPathFor(s)));
|
|
147
|
+
}
|
|
148
|
+
// Past the crash window
|
|
149
|
+
await new Promise((r) => setTimeout(r, 350));
|
|
150
|
+
if (s.child.exitCode !== null) {
|
|
151
|
+
const log = existsSync(keeperLogFor(s))
|
|
152
|
+
? readFileSync(keeperLogFor(s), "utf8")
|
|
153
|
+
: "(no log)";
|
|
154
|
+
throw new Error(`keeper exited prematurely (code=${s.child.exitCode}). Log:\n${log}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function writeLineToKeeper(s: SpawnedKeeper, line: string): Promise<void> {
|
|
159
|
+
await new Promise<void>((resolve, reject) => {
|
|
160
|
+
const sock = net.createConnection(sockPathFor(s));
|
|
161
|
+
sock.once("connect", () => {
|
|
162
|
+
sock.end(line + "\n", "utf8", () => resolve());
|
|
163
|
+
});
|
|
164
|
+
sock.once("error", reject);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function killAndAwait(s: SpawnedKeeper, signal: NodeJS.Signals = "SIGTERM"): Promise<{ code: number | null; signal: NodeJS.Signals | null }> {
|
|
169
|
+
if (s.child.exitCode === null) s.child.kill(signal);
|
|
170
|
+
return s.exited;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Cleanup state across tests
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
const trackedKeepers: SpawnedKeeper[] = [];
|
|
178
|
+
beforeEach(() => {
|
|
179
|
+
trackedKeepers.length = 0;
|
|
180
|
+
});
|
|
181
|
+
afterEach(async () => {
|
|
182
|
+
for (const k of trackedKeepers) {
|
|
183
|
+
if (k.child.exitCode === null) {
|
|
184
|
+
k.child.kill("SIGKILL");
|
|
185
|
+
await k.exited.catch(() => undefined);
|
|
186
|
+
}
|
|
187
|
+
try { rmSync(k.home, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
function track(s: SpawnedKeeper): SpawnedKeeper {
|
|
192
|
+
trackedKeepers.push(s);
|
|
193
|
+
return s;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Tests
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
describe.skipIf(process.platform === "win32")("rpc-keeper (Unix UDS)", () => {
|
|
201
|
+
it("3.2 forwards a JSON line from UDS connection to mock-pi stdin", async () => {
|
|
202
|
+
const k = track(await spawnKeeper());
|
|
203
|
+
await readyKeeper(k);
|
|
204
|
+
|
|
205
|
+
const line = '{"type":"prompt","message":"hello","id":"1"}';
|
|
206
|
+
await writeLineToKeeper(k, line);
|
|
207
|
+
|
|
208
|
+
// Mock pi appends each line to MOCK_PI_LOG. Wait for it.
|
|
209
|
+
await waitFor(() => existsSync(k.mockPiLog) && readFileSync(k.mockPiLog, "utf8").includes("hello"));
|
|
210
|
+
|
|
211
|
+
const contents = readFileSync(k.mockPiLog, "utf8");
|
|
212
|
+
expect(contents.trimEnd()).toBe(line);
|
|
213
|
+
|
|
214
|
+
// The keeper still has pi alive — clean up.
|
|
215
|
+
await killAndAwait(k);
|
|
216
|
+
}, 10_000);
|
|
217
|
+
|
|
218
|
+
it("3.3 keeper exits 0 and unlinks files when pi exits", async () => {
|
|
219
|
+
const k = track(await spawnKeeper());
|
|
220
|
+
await readyKeeper(k);
|
|
221
|
+
|
|
222
|
+
expect(existsSync(sockPathFor(k))).toBe(true);
|
|
223
|
+
expect(existsSync(pidPathFor(k))).toBe(true);
|
|
224
|
+
|
|
225
|
+
// Read the keeper's pi child PID via lsof? Simpler: kill the keeper's
|
|
226
|
+
// parent's pi child by PGID-equivalent strategy — but that's racy.
|
|
227
|
+
// Instead, use the shutdown path that's the same code: send SIGTERM
|
|
228
|
+
// to the keeper, which closes pi's stdin → mock-pi sees EOF → exit 0.
|
|
229
|
+
// This test exercises the shared shutdown handler path that ALSO
|
|
230
|
+
// fires on pi-exit (via child.on("exit") → shutdown(0)).
|
|
231
|
+
const result = await killAndAwait(k, "SIGTERM");
|
|
232
|
+
|
|
233
|
+
expect(result.code).toBe(0);
|
|
234
|
+
expect(existsSync(sockPathFor(k))).toBe(false);
|
|
235
|
+
expect(existsSync(pidPathFor(k))).toBe(false);
|
|
236
|
+
}, 10_000);
|
|
237
|
+
|
|
238
|
+
it("3.3b keeper exits 0 and unlinks files when pi child exits naturally", async () => {
|
|
239
|
+
// Stronger version of 3.3: trigger pi's exit (not keeper's signal).
|
|
240
|
+
// We connect, send EOF to mock-pi indirectly by closing all input
|
|
241
|
+
// routes. Easiest path: write a line and end the conn — mock-pi will
|
|
242
|
+
// log the line but won't exit (it waits for stdin EOF, which only
|
|
243
|
+
// closes when keeper closes pi.stdin, which only happens on keeper
|
|
244
|
+
// shutdown). So instead: send SIGTERM to the mock-pi child PID by
|
|
245
|
+
// searching its process tree.
|
|
246
|
+
const k = track(await spawnKeeper());
|
|
247
|
+
await readyKeeper(k);
|
|
248
|
+
|
|
249
|
+
// Find mock-pi children of the keeper (best-effort via /proc on Linux,
|
|
250
|
+
// ps on macOS).
|
|
251
|
+
const mockPids = await findChildPids(k.child.pid!);
|
|
252
|
+
expect(mockPids.length).toBeGreaterThan(0);
|
|
253
|
+
|
|
254
|
+
for (const pid of mockPids) {
|
|
255
|
+
try { process.kill(pid, "SIGTERM"); } catch { /* gone */ }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const result = await k.exited;
|
|
259
|
+
expect(result.code).toBe(0);
|
|
260
|
+
expect(existsSync(sockPathFor(k))).toBe(false);
|
|
261
|
+
expect(existsSync(pidPathFor(k))).toBe(false);
|
|
262
|
+
}, 10_000);
|
|
263
|
+
|
|
264
|
+
it("3.4 stale-socket recovery (pre-create socket file, keeper unlinks + retries)", async () => {
|
|
265
|
+
const sessionId = makeSessionId();
|
|
266
|
+
const home = makeShortHome();
|
|
267
|
+
mkdirSync(sessionsDirIn(home), { recursive: true });
|
|
268
|
+
// Pre-create a regular file at the socket path. Bind fails with EADDRINUSE.
|
|
269
|
+
writeFileSync(sockPathIn(home, sessionId), "", { mode: 0o600 });
|
|
270
|
+
|
|
271
|
+
const k = track(await spawnKeeper({ sessionId, home }));
|
|
272
|
+
await readyKeeper(k);
|
|
273
|
+
|
|
274
|
+
// Recovery succeeded: the path is now bound (existsSync returns true for sockets too).
|
|
275
|
+
expect(existsSync(sockPathFor(k))).toBe(true);
|
|
276
|
+
|
|
277
|
+
await killAndAwait(k);
|
|
278
|
+
}, 10_000);
|
|
279
|
+
|
|
280
|
+
it("3.5 crash-detection: mock-pi exits immediately, keeper exits non-zero within 1s", async () => {
|
|
281
|
+
const k = track(await spawnKeeper({ mode: "crash" }));
|
|
282
|
+
|
|
283
|
+
// Should NOT reach readyKeeper — wait for exit instead, with a tight bound.
|
|
284
|
+
const result = await Promise.race([
|
|
285
|
+
k.exited,
|
|
286
|
+
new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((_, reject) =>
|
|
287
|
+
setTimeout(() => reject(new Error("keeper did not exit within 2s")), 2000),
|
|
288
|
+
),
|
|
289
|
+
]);
|
|
290
|
+
expect(result.code).not.toBe(0);
|
|
291
|
+
|
|
292
|
+
// Files cleaned up
|
|
293
|
+
expect(existsSync(sockPathFor(k))).toBe(false);
|
|
294
|
+
expect(existsSync(pidPathFor(k))).toBe(false);
|
|
295
|
+
}, 5_000);
|
|
296
|
+
|
|
297
|
+
it("3.6 concurrent connections — 3 simultaneous UDS connections, all 3 lines forwarded", async () => {
|
|
298
|
+
const k = track(await spawnKeeper());
|
|
299
|
+
await readyKeeper(k);
|
|
300
|
+
|
|
301
|
+
const lines = [
|
|
302
|
+
'{"type":"prompt","message":"line-A","id":"a"}',
|
|
303
|
+
'{"type":"prompt","message":"line-B","id":"b"}',
|
|
304
|
+
'{"type":"prompt","message":"line-C","id":"c"}',
|
|
305
|
+
];
|
|
306
|
+
|
|
307
|
+
await Promise.all(lines.map((line) => writeLineToKeeper(k, line)));
|
|
308
|
+
|
|
309
|
+
await waitFor(() => {
|
|
310
|
+
if (!existsSync(k.mockPiLog)) return false;
|
|
311
|
+
const c = readFileSync(k.mockPiLog, "utf8");
|
|
312
|
+
return lines.every((l) => c.includes(l));
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const out = readFileSync(k.mockPiLog, "utf8")
|
|
316
|
+
.split("\n")
|
|
317
|
+
.filter((l) => l.length > 0)
|
|
318
|
+
.sort();
|
|
319
|
+
expect(out).toEqual([...lines].sort());
|
|
320
|
+
|
|
321
|
+
await killAndAwait(k);
|
|
322
|
+
}, 10_000);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe.skipIf(process.platform !== "win32")("rpc-keeper (Windows named pipe)", () => {
|
|
326
|
+
// Task 3.7: same scenarios as Unix, gated by platform.
|
|
327
|
+
// Windows path uses `\\.\pipe\pi-rpc-<sid>` and `<sessionsDir>/pi-rpc-<sid>.pid`.
|
|
328
|
+
// Leaving as a single smoke test for now — full coverage of all 3.x cases
|
|
329
|
+
// requires a Windows CI runner. The spec scenarios apply identically; the
|
|
330
|
+
// helper functions above already path-switch by platform.
|
|
331
|
+
|
|
332
|
+
it("3.7 keeper bound named pipe, forwards a line, exits cleanly on signal", async () => {
|
|
333
|
+
const k = track(await spawnKeeper());
|
|
334
|
+
await readyKeeper(k);
|
|
335
|
+
|
|
336
|
+
const line = '{"type":"prompt","message":"hello","id":"1"}';
|
|
337
|
+
await writeLineToKeeper(k, line);
|
|
338
|
+
|
|
339
|
+
await waitFor(() => existsSync(k.mockPiLog) && readFileSync(k.mockPiLog, "utf8").includes("hello"));
|
|
340
|
+
|
|
341
|
+
const result = await killAndAwait(k);
|
|
342
|
+
expect(result.code).toBe(0);
|
|
343
|
+
// Named pipe path is virtual on Windows — only the PID sidecar is unlinked.
|
|
344
|
+
expect(existsSync(pidPathFor(k))).toBe(false);
|
|
345
|
+
}, 15_000);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
// Helpers
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
async function findChildPids(parentPid: number): Promise<number[]> {
|
|
353
|
+
// macOS / Linux: `ps -o pid= --ppid <pid>`
|
|
354
|
+
return new Promise((resolve) => {
|
|
355
|
+
// -A is required to see processes outside the calling terminal session;
|
|
356
|
+
// vitest workers don't have a controlling tty, so without -A the keeper's
|
|
357
|
+
// child node process is invisible.
|
|
358
|
+
const ps = spawn("ps", ["-A", "-o", "pid=", "-o", "ppid="], { stdio: ["ignore", "pipe", "ignore"] });
|
|
359
|
+
let out = "";
|
|
360
|
+
ps.stdout.on("data", (b) => { out += b; });
|
|
361
|
+
ps.once("exit", () => {
|
|
362
|
+
const pids: number[] = [];
|
|
363
|
+
for (const raw of out.split("\n")) {
|
|
364
|
+
const m = raw.trim().match(/^(\d+)\s+(\d+)$/);
|
|
365
|
+
if (m && Number(m[2]) === parentPid) pids.push(Number(m[1]));
|
|
366
|
+
}
|
|
367
|
+
resolve(pids);
|
|
368
|
+
});
|
|
369
|
+
ps.once("error", () => resolve([]));
|
|
370
|
+
});
|
|
371
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatch router for the bridge→server `dispatch_extension_command`
|
|
3
|
+
* message. Forwards the slash command to the per-session RPC keeper UDS
|
|
4
|
+
* and emits the terminal `command_feedback` event to browser subscribers
|
|
5
|
+
* (the bridge already emitted `started`).
|
|
6
|
+
*
|
|
7
|
+
* Optimistic completion (design.md Decision 7): a successful UDS write
|
|
8
|
+
* means pi RECEIVED the line; if pi rejects it, an `extension_error`
|
|
9
|
+
* event flows back over the bridge WS path and is rendered as a separate
|
|
10
|
+
* chat row by the existing event-reducer.
|
|
11
|
+
*
|
|
12
|
+
* The terminal `completed` / `error` event MUST be persisted in the
|
|
13
|
+
* dashboard's `eventStore` (same path the bridge's `event_forward` takes)
|
|
14
|
+
* — otherwise the chat pill renders the bridge's persisted `started`
|
|
15
|
+
* but the server's optimistic terminal is ephemeral and the upsert never
|
|
16
|
+
* fires on browser reattach. Stuck-on-"in progress" was the visible
|
|
17
|
+
* symptom of routing the broadcast directly via `sendToSubscribers`
|
|
18
|
+
* without storing first.
|
|
19
|
+
*
|
|
20
|
+
* See change: add-rpc-stdin-dispatch-with-keeper-sidecar (Phase 8).
|
|
21
|
+
*/
|
|
22
|
+
import type { DispatchExtensionCommandMessage } from "@blackbelt-technology/pi-dashboard-shared/protocol.js";
|
|
23
|
+
import type { HeadlessPidRegistry } from "../headless-pid-registry.js";
|
|
24
|
+
|
|
25
|
+
export interface DispatchRouterContext {
|
|
26
|
+
headlessPidRegistry: HeadlessPidRegistry;
|
|
27
|
+
/**
|
|
28
|
+
* Persist + broadcast a `command_feedback` event for `sessionId`.
|
|
29
|
+
* Wired by `event-wiring.ts` to `eventStore.insertEvent` +
|
|
30
|
+
* `browserGateway.broadcastEvent` so the event survives browser
|
|
31
|
+
* reattach via the existing replay path.
|
|
32
|
+
*/
|
|
33
|
+
emitCommandFeedback(
|
|
34
|
+
sessionId: string,
|
|
35
|
+
command: string,
|
|
36
|
+
status: "completed" | "error",
|
|
37
|
+
message?: string,
|
|
38
|
+
): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the pi RPC line forwarded over the keeper UDS. Pure helper so
|
|
43
|
+
* unit tests don't have to JSON-parse to assert the shape.
|
|
44
|
+
*/
|
|
45
|
+
export function buildPiRpcLine(command: string, requestId: string): string {
|
|
46
|
+
return JSON.stringify({ type: "prompt", message: command, id: requestId });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ERR_NO_KEEPER = "RPC keeper unavailable for this session";
|
|
50
|
+
const ERR_WRITE_PREFIX = "Failed to write RPC line";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Handle `dispatch_extension_command`: write the pi RPC line to the
|
|
54
|
+
* session's keeper UDS, then persist + broadcast the optimistic terminal
|
|
55
|
+
* `command_feedback`. Never throws; failures surface as
|
|
56
|
+
* `command_feedback {status:"error"}` via `emitCommandFeedback`.
|
|
57
|
+
*/
|
|
58
|
+
export async function handleDispatchExtensionCommand(
|
|
59
|
+
msg: DispatchExtensionCommandMessage,
|
|
60
|
+
ctx: DispatchRouterContext,
|
|
61
|
+
): Promise<void> {
|
|
62
|
+
const { sessionId, command, requestId } = msg;
|
|
63
|
+
const line = buildPiRpcLine(command, requestId);
|
|
64
|
+
console.error(
|
|
65
|
+
`[dispatch-router] dispatch_extension_command sid=${sessionId} cmd=${command} reqId=${requestId.slice(0, 8)}`,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
let ok = false;
|
|
69
|
+
try {
|
|
70
|
+
ok = await ctx.headlessPidRegistry.writeRpc(sessionId, line);
|
|
71
|
+
} catch (err: any) {
|
|
72
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
73
|
+
ctx.emitCommandFeedback(sessionId, command, "error", `${ERR_WRITE_PREFIX}: ${reason}`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!ok) {
|
|
78
|
+
console.error(`[dispatch-router] writeRpc returned false for sid=${sessionId} (no keeper or socket gone)`);
|
|
79
|
+
ctx.emitCommandFeedback(sessionId, command, "error", ERR_NO_KEEPER);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
console.error(`[dispatch-router] writeRpc OK for sid=${sessionId}, emitting optimistic completed`);
|
|
84
|
+
ctx.emitCommandFeedback(sessionId, command, "completed");
|
|
85
|
+
}
|