@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
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawn-register watchdog.
|
|
3
|
+
*
|
|
4
|
+
* Arms a per-spawn timer after every successful `spawnPiSession`. If the
|
|
5
|
+
* spawned pi session never sends `session_register` within the timeout
|
|
6
|
+
* window, emits `spawn_register_timeout` to the originating WebSocket.
|
|
7
|
+
*
|
|
8
|
+
* Two index maps handle the two spawn families:
|
|
9
|
+
* - `byPid` — headless spawns where the dashboard owns the PID.
|
|
10
|
+
* - `byCwd` — tmux/wt/wsl-tmux spawns where any `session_register` from
|
|
11
|
+
* that directory clears the watch.
|
|
12
|
+
*
|
|
13
|
+
* Late registrations (pi finally registers after the watchdog fired) are
|
|
14
|
+
* detected via `recentlyFired` (60 s TTL) and cause a `spawn_register_recovered`
|
|
15
|
+
* message to auto-clear the timeout banner.
|
|
16
|
+
*
|
|
17
|
+
* See change: spawn-failure-diagnostics.
|
|
18
|
+
*/
|
|
19
|
+
import WebSocket from "ws";
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import type { SpawnMechanism } from "@blackbelt-technology/pi-dashboard-shared/platform/spawn-mechanism.js";
|
|
22
|
+
import type {
|
|
23
|
+
SpawnRegisterTimeoutMessage,
|
|
24
|
+
SpawnRegisterRecoveredMessage,
|
|
25
|
+
} from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
|
|
26
|
+
import { clampSpawnRegisterTimeoutMs, loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
27
|
+
import { appendSpawnFailure } from "./spawn-failure-log.js";
|
|
28
|
+
|
|
29
|
+
export interface WatchdogArmOptions {
|
|
30
|
+
pid?: number;
|
|
31
|
+
cwd: string;
|
|
32
|
+
mechanism: SpawnMechanism;
|
|
33
|
+
logPath?: string;
|
|
34
|
+
ws: WebSocket;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface Entry {
|
|
38
|
+
timer: ReturnType<typeof setTimeout>;
|
|
39
|
+
cwd: string;
|
|
40
|
+
pid?: number;
|
|
41
|
+
mechanism: SpawnMechanism;
|
|
42
|
+
logPath?: string;
|
|
43
|
+
ws: WebSocket;
|
|
44
|
+
timeoutMs: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface RecentlyFiredEntry {
|
|
48
|
+
firedAt: number;
|
|
49
|
+
pid?: number;
|
|
50
|
+
ws: WebSocket;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const RECENTLY_FIRED_TTL_MS = 60_000;
|
|
54
|
+
|
|
55
|
+
export class SpawnRegisterWatchdog {
|
|
56
|
+
/** Default timeout used when arm() callers do not supply one. */
|
|
57
|
+
readonly timeoutMs: number;
|
|
58
|
+
private readonly byPid = new Map<number, Entry>();
|
|
59
|
+
private readonly byCwd = new Map<string, Entry>();
|
|
60
|
+
private readonly recentlyFired = new Map<string, RecentlyFiredEntry>();
|
|
61
|
+
|
|
62
|
+
constructor(timeoutMs: number) {
|
|
63
|
+
this.timeoutMs = clampSpawnRegisterTimeoutMs(timeoutMs);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
arm(opts: WatchdogArmOptions & { timeoutMs?: number }): void {
|
|
67
|
+
// Read-on-arm: caller passes the current config value so a Settings change
|
|
68
|
+
// takes effect on the next spawn without a server restart.
|
|
69
|
+
// See change: spawn-failure-diagnostics (fix W1).
|
|
70
|
+
const effectiveTimeout = clampSpawnRegisterTimeoutMs(opts.timeoutMs ?? this.timeoutMs);
|
|
71
|
+
const { pid, cwd, mechanism, logPath, ws } = opts;
|
|
72
|
+
const entry: Entry = {
|
|
73
|
+
timer: null as unknown as ReturnType<typeof setTimeout>,
|
|
74
|
+
cwd, pid, mechanism, logPath, ws,
|
|
75
|
+
timeoutMs: effectiveTimeout,
|
|
76
|
+
};
|
|
77
|
+
entry.timer = setTimeout(() => this._fireEntry(entry), effectiveTimeout);
|
|
78
|
+
// Always index by cwd so a `session_register` clears the watchdog even
|
|
79
|
+
// when the bridge's reported pid differs from the spawner's pid (e.g.
|
|
80
|
+
// Unix headless wraps pi in `sh -c "tail -f /dev/null | pi …"`, so
|
|
81
|
+
// spawnResult.pid is the sh wrapper, not pi). Index by pid additionally
|
|
82
|
+
// for late-recovery lookup. Replace any prior entry for the same
|
|
83
|
+
// cwd/pid to avoid leaking timers.
|
|
84
|
+
const priorCwd = this.byCwd.get(cwd);
|
|
85
|
+
if (priorCwd) clearTimeout(priorCwd.timer);
|
|
86
|
+
this.byCwd.set(cwd, entry);
|
|
87
|
+
if (pid !== undefined) {
|
|
88
|
+
const priorPid = this.byPid.get(pid);
|
|
89
|
+
if (priorPid && priorPid !== priorCwd) clearTimeout(priorPid.timer);
|
|
90
|
+
this.byPid.set(pid, entry);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
clearByPid(pid: number): void {
|
|
95
|
+
const entry = this.byPid.get(pid);
|
|
96
|
+
if (entry) {
|
|
97
|
+
clearTimeout(entry.timer);
|
|
98
|
+
this.byPid.delete(pid);
|
|
99
|
+
// Also clear cwd entry if it points at the same arm.
|
|
100
|
+
const cwdEntry = this.byCwd.get(entry.cwd);
|
|
101
|
+
if (cwdEntry === entry) this.byCwd.delete(entry.cwd);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// Check for late recovery.
|
|
105
|
+
this._checkRecoveryByPid(pid);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
clearByCwd(cwd: string): void {
|
|
109
|
+
const entry = this.byCwd.get(cwd);
|
|
110
|
+
if (entry) {
|
|
111
|
+
clearTimeout(entry.timer);
|
|
112
|
+
this.byCwd.delete(cwd);
|
|
113
|
+
// Also clear pid entry if it points at the same arm.
|
|
114
|
+
if (entry.pid !== undefined) {
|
|
115
|
+
const pidEntry = this.byPid.get(entry.pid);
|
|
116
|
+
if (pidEntry === entry) this.byPid.delete(entry.pid);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// Check for late recovery.
|
|
121
|
+
this._checkRecoveryByCwd(cwd);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private _fireEntry(entry: Entry): void {
|
|
125
|
+
const { cwd, pid, logPath, ws, timeoutMs: entryTimeoutMs } = entry;
|
|
126
|
+
// Remove from active maps.
|
|
127
|
+
if (pid !== undefined) {
|
|
128
|
+
const pidEntry = this.byPid.get(pid);
|
|
129
|
+
if (pidEntry === entry) this.byPid.delete(pid);
|
|
130
|
+
}
|
|
131
|
+
const cwdEntry = this.byCwd.get(cwd);
|
|
132
|
+
if (cwdEntry === entry) this.byCwd.delete(cwd);
|
|
133
|
+
|
|
134
|
+
// Record in recentlyFired for late-recovery detection.
|
|
135
|
+
this.recentlyFired.set(cwd, { firedAt: Date.now(), pid, ws });
|
|
136
|
+
|
|
137
|
+
// Read stderr tail if logPath available.
|
|
138
|
+
let stderrTail: string | undefined;
|
|
139
|
+
if (logPath) {
|
|
140
|
+
stderrTail = readLogTail(logPath);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Persist the timeout to the rolling failure log. See change: spawn-failure-diagnostics.
|
|
144
|
+
appendSpawnFailure({
|
|
145
|
+
ts: new Date().toISOString(),
|
|
146
|
+
cwd,
|
|
147
|
+
strategy: entry.mechanism,
|
|
148
|
+
code: "REGISTER_TIMEOUT",
|
|
149
|
+
message: `Pi session spawned but never registered (timeout ${this.timeoutMs}ms)`,
|
|
150
|
+
...(pid !== undefined ? { pid } : {}),
|
|
151
|
+
...(stderrTail ? { stderrTail } : {}),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
155
|
+
|
|
156
|
+
const msg: SpawnRegisterTimeoutMessage = {
|
|
157
|
+
type: "spawn_register_timeout",
|
|
158
|
+
cwd,
|
|
159
|
+
timeoutMs: entryTimeoutMs,
|
|
160
|
+
...(pid !== undefined ? { pid } : {}),
|
|
161
|
+
...(stderrTail ? { stderrTail } : {}),
|
|
162
|
+
};
|
|
163
|
+
ws.send(JSON.stringify(msg));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private _checkRecoveryByPid(pid: number): void {
|
|
167
|
+
// recentlyFired is keyed by cwd; scan to find matching pid.
|
|
168
|
+
for (const [cwd, fired] of this.recentlyFired) {
|
|
169
|
+
if (fired.pid === pid) {
|
|
170
|
+
this._emitRecovery(cwd, fired);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private _checkRecoveryByCwd(cwd: string): void {
|
|
177
|
+
const fired = this.recentlyFired.get(cwd);
|
|
178
|
+
if (!fired) return;
|
|
179
|
+
this._emitRecovery(cwd, fired);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private _emitRecovery(cwd: string, fired: RecentlyFiredEntry): void {
|
|
183
|
+
// TTL check.
|
|
184
|
+
if (Date.now() - fired.firedAt > RECENTLY_FIRED_TTL_MS) {
|
|
185
|
+
this.recentlyFired.delete(cwd);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
this.recentlyFired.delete(cwd);
|
|
190
|
+
|
|
191
|
+
if (fired.ws.readyState !== WebSocket.OPEN) return;
|
|
192
|
+
|
|
193
|
+
const msg: SpawnRegisterRecoveredMessage = {
|
|
194
|
+
type: "spawn_register_recovered",
|
|
195
|
+
cwd,
|
|
196
|
+
...(fired.pid !== undefined ? { pid: fired.pid } : {}),
|
|
197
|
+
};
|
|
198
|
+
fired.ws.send(JSON.stringify(msg));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Singleton ────────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
let _instance: SpawnRegisterWatchdog | null = null;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Lazy singleton. On first call, reads `spawnRegisterTimeoutMs` from config.
|
|
208
|
+
* Tests can swap the instance via `_setSpawnRegisterWatchdogForTests`.
|
|
209
|
+
*/
|
|
210
|
+
export function getSpawnRegisterWatchdog(): SpawnRegisterWatchdog {
|
|
211
|
+
if (!_instance) {
|
|
212
|
+
const config = loadConfig();
|
|
213
|
+
_instance = new SpawnRegisterWatchdog(config.spawnRegisterTimeoutMs);
|
|
214
|
+
}
|
|
215
|
+
return _instance;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Swap the singleton for tests. Pass `null` to reset. */
|
|
219
|
+
export function _setSpawnRegisterWatchdogForTests(w: SpawnRegisterWatchdog | null): void {
|
|
220
|
+
_instance = w;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
function readLogTail(filePath: string, maxBytes = 4096): string | undefined {
|
|
226
|
+
try {
|
|
227
|
+
const buf = readFileSync(filePath);
|
|
228
|
+
if (!buf.length) return undefined;
|
|
229
|
+
const slice = buf.length <= maxBytes ? buf : buf.slice(buf.length - maxBytes);
|
|
230
|
+
let start = 0;
|
|
231
|
+
while (start < slice.length && (slice[start]! & 0xC0) === 0x80) start++;
|
|
232
|
+
return slice.slice(start).toString("utf-8");
|
|
233
|
+
} catch {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -185,7 +185,18 @@ export function createTerminalManager(options?: TerminalManagerOptions): Termina
|
|
|
185
185
|
try {
|
|
186
186
|
const msg: TerminalControlMessage = JSON.parse(str);
|
|
187
187
|
if (msg.type === "resize") {
|
|
188
|
-
|
|
188
|
+
// Defense in depth: reject degenerate resize messages.
|
|
189
|
+
// A PTY at <2 cols/rows is non-functional for every supported
|
|
190
|
+
// shell binding; no legitimate user intent maps there. xterm's
|
|
191
|
+
// FitAddon is supposed to guard against zero, but a transient
|
|
192
|
+
// display:none container measured during a route transition
|
|
193
|
+
// can leak a 1 through. See change:
|
|
194
|
+
// fix-terminal-half-height-dual-mount.
|
|
195
|
+
if (msg.cols < 2 || msg.rows < 2) {
|
|
196
|
+
// ignore — keep previous PTY dimensions
|
|
197
|
+
} else {
|
|
198
|
+
entry.pty.resize(msg.cols, msg.rows);
|
|
199
|
+
}
|
|
189
200
|
} else if (msg.type === "title") {
|
|
190
201
|
// title control message — handled elsewhere
|
|
191
202
|
} else {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression: `resolveNpmArgv` from `bootstrap-install.ts` MUST consult
|
|
3
|
+
* the injected `ToolRegistry` for `npm` before falling back to the
|
|
4
|
+
* platform `npm` / `npm.cmd` PATH binary.
|
|
5
|
+
*
|
|
6
|
+
* The change `embed-managed-node-runtime` adds a managedRuntime
|
|
7
|
+
* strategy to the npm chain in the registry. If a future refactor
|
|
8
|
+
* accidentally bypasses the registry inside `resolveNpmArgv`, the
|
|
9
|
+
* managed Node runtime would stop being preferred for shared bootstrap
|
|
10
|
+
* spawns \u2014 the user-visible regression class this whole change exists
|
|
11
|
+
* to prevent.
|
|
12
|
+
*
|
|
13
|
+
* See change: embed-managed-node-runtime (task 4.2).
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, it } from "vitest";
|
|
16
|
+
import { resolveNpmArgv } from "../bootstrap-install.js";
|
|
17
|
+
import type {
|
|
18
|
+
Resolution,
|
|
19
|
+
ToolRegistry,
|
|
20
|
+
} from "../tool-registry/index.js";
|
|
21
|
+
|
|
22
|
+
function fakeRegistry(opts: {
|
|
23
|
+
hasNpm?: boolean;
|
|
24
|
+
resolveResult?: Partial<Resolution>;
|
|
25
|
+
}): ToolRegistry {
|
|
26
|
+
// Only the methods `resolveNpmArgv` actually calls.
|
|
27
|
+
return {
|
|
28
|
+
has: (name: string) => name === "npm" && (opts.hasNpm ?? true),
|
|
29
|
+
resolve: () =>
|
|
30
|
+
({
|
|
31
|
+
name: "npm",
|
|
32
|
+
ok: true,
|
|
33
|
+
path: "/managed/node/bin/npm",
|
|
34
|
+
source: "managed",
|
|
35
|
+
tried: [],
|
|
36
|
+
resolvedAt: Date.now(),
|
|
37
|
+
...opts.resolveResult,
|
|
38
|
+
}) as Resolution,
|
|
39
|
+
} as unknown as ToolRegistry;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("resolveNpmArgv", () => {
|
|
43
|
+
it("explicit npmArgv wins over registry", () => {
|
|
44
|
+
const argv = resolveNpmArgv({
|
|
45
|
+
npmArgv: ["/explicit/node", "/explicit/npm-cli.js"],
|
|
46
|
+
registry: fakeRegistry({}),
|
|
47
|
+
});
|
|
48
|
+
expect(argv).toEqual(["/explicit/node", "/explicit/npm-cli.js"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("uses ToolRegistry.resolve('npm') when no explicit argv", () => {
|
|
52
|
+
const argv = resolveNpmArgv({
|
|
53
|
+
registry: fakeRegistry({
|
|
54
|
+
resolveResult: { ok: true, path: "/managed/node/bin/npm" } as Partial<Resolution>,
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
expect(argv).toEqual(["/managed/node/bin/npm"]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("falls back to npm/npm.cmd on PATH when registry has no entry", () => {
|
|
61
|
+
const argv = resolveNpmArgv({
|
|
62
|
+
registry: {
|
|
63
|
+
has: () => false,
|
|
64
|
+
resolve: () => {
|
|
65
|
+
throw new Error("should not be called");
|
|
66
|
+
},
|
|
67
|
+
} as unknown as ToolRegistry,
|
|
68
|
+
});
|
|
69
|
+
expect(argv).toHaveLength(1);
|
|
70
|
+
expect(argv[0]).toMatch(/^npm(\.cmd)?$/);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -12,8 +12,13 @@ import type {
|
|
|
12
12
|
BrowserPromptDismissMessage,
|
|
13
13
|
BrowserPromptCancelMessage,
|
|
14
14
|
BrowserExtUiDecoratorMessage,
|
|
15
|
+
BrowserAssetRegisterMessage,
|
|
15
16
|
} from "../browser-protocol.js";
|
|
16
|
-
import type {
|
|
17
|
+
import type {
|
|
18
|
+
ExtensionToServerMessage,
|
|
19
|
+
ExtUiDecoratorMessage,
|
|
20
|
+
AssetRegisterMessage,
|
|
21
|
+
} from "../protocol.js";
|
|
17
22
|
import type { DecoratorDescriptor } from "../types.js";
|
|
18
23
|
|
|
19
24
|
// Type-level assertion: if these types are NOT in the union, this will fail to compile.
|
|
@@ -26,6 +31,11 @@ type _PromptCancelInUnion = AssertExtends<BrowserPromptCancelMessage, ServerToBr
|
|
|
26
31
|
// esbuild strips the switch arms in production builds.
|
|
27
32
|
type _ExtUiDecoratorInExtensionUnion = AssertExtends<ExtUiDecoratorMessage, ExtensionToServerMessage>;
|
|
28
33
|
type _ExtUiDecoratorInBrowserUnion = AssertExtends<BrowserExtUiDecoratorMessage, ServerToBrowserMessage>;
|
|
34
|
+
// chat-markdown-local-images-and-math: asset_register must live in BOTH the
|
|
35
|
+
// extension→server union (so the server's switch arm survives esbuild) AND
|
|
36
|
+
// the server→browser union (so the client's reducer arm survives esbuild).
|
|
37
|
+
type _AssetRegisterInExtensionUnion = AssertExtends<AssetRegisterMessage, ExtensionToServerMessage>;
|
|
38
|
+
type _AssetRegisterInBrowserUnion = AssertExtends<BrowserAssetRegisterMessage, ServerToBrowserMessage>;
|
|
29
39
|
|
|
30
40
|
// Runtime verification that the type discriminants are reachable in a switch
|
|
31
41
|
function extractPromptType(msg: ServerToBrowserMessage): string | null {
|
|
@@ -119,3 +129,39 @@ describe("ext_ui_decorator is a member of both protocol unions", () => {
|
|
|
119
129
|
expect(msg.descriptor.kind).toBe("footer-segment");
|
|
120
130
|
});
|
|
121
131
|
});
|
|
132
|
+
|
|
133
|
+
// chat-markdown-local-images-and-math: asset_register switch-arm reachability.
|
|
134
|
+
function extractAssetHash(msg: ServerToBrowserMessage): string | null {
|
|
135
|
+
switch (msg.type) {
|
|
136
|
+
case "asset_register":
|
|
137
|
+
return msg.hash;
|
|
138
|
+
default:
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
describe("asset_register is a member of both protocol unions", () => {
|
|
144
|
+
it("server→browser asset_register is a valid discriminant", () => {
|
|
145
|
+
const msg: BrowserAssetRegisterMessage = {
|
|
146
|
+
type: "asset_register",
|
|
147
|
+
sessionId: "s1",
|
|
148
|
+
hash: "abc1234567890123",
|
|
149
|
+
mimeType: "image/png",
|
|
150
|
+
data: "iVBORw0KGgo=",
|
|
151
|
+
};
|
|
152
|
+
expect(extractAssetHash(msg)).toBe("abc1234567890123");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("extension→server asset_register carries the same shape", () => {
|
|
156
|
+
const msg: AssetRegisterMessage = {
|
|
157
|
+
type: "asset_register",
|
|
158
|
+
sessionId: "s1",
|
|
159
|
+
hash: "abc1234567890123",
|
|
160
|
+
mimeType: "image/svg+xml",
|
|
161
|
+
data: "PHN2Zy8+",
|
|
162
|
+
};
|
|
163
|
+
expect(msg.type).toBe("asset_register");
|
|
164
|
+
expect(msg.hash).toBe("abc1234567890123");
|
|
165
|
+
expect(msg.mimeType).toBe("image/svg+xml");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -471,3 +471,51 @@ describe("loadConfig reattachPlacement", () => {
|
|
|
471
471
|
expect(content.reattachPlacement).toBeUndefined();
|
|
472
472
|
});
|
|
473
473
|
});
|
|
474
|
+
|
|
475
|
+
describe("loadConfig spawnRegisterTimeoutMs", () => {
|
|
476
|
+
let testDir: string;
|
|
477
|
+
let configFile: string;
|
|
478
|
+
let origHome: string;
|
|
479
|
+
|
|
480
|
+
beforeEach(() => {
|
|
481
|
+
testDir = path.join(os.tmpdir(), `test-config-srt-${Date.now()}`);
|
|
482
|
+
fs.mkdirSync(path.join(testDir, ".pi", "dashboard"), { recursive: true });
|
|
483
|
+
configFile = path.join(testDir, ".pi", "dashboard", "config.json");
|
|
484
|
+
origHome = process.env.HOME!;
|
|
485
|
+
process.env.HOME = testDir;
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
afterEach(() => {
|
|
489
|
+
process.env.HOME = origHome;
|
|
490
|
+
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("defaults to 30000 when field is omitted", () => {
|
|
494
|
+
expect(loadConfig().spawnRegisterTimeoutMs).toBe(30000);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("preserves in-range value", () => {
|
|
498
|
+
fs.writeFileSync(configFile, JSON.stringify({ spawnRegisterTimeoutMs: 45000 }));
|
|
499
|
+
expect(loadConfig().spawnRegisterTimeoutMs).toBe(45000);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("clamps below-range value to 5000", () => {
|
|
503
|
+
fs.writeFileSync(configFile, JSON.stringify({ spawnRegisterTimeoutMs: 1000 }));
|
|
504
|
+
expect(loadConfig().spawnRegisterTimeoutMs).toBe(5000);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("clamps above-range value to 120000", () => {
|
|
508
|
+
fs.writeFileSync(configFile, JSON.stringify({ spawnRegisterTimeoutMs: 999999 }));
|
|
509
|
+
expect(loadConfig().spawnRegisterTimeoutMs).toBe(120000);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("falls back to default for non-number string", () => {
|
|
513
|
+
fs.writeFileSync(configFile, JSON.stringify({ spawnRegisterTimeoutMs: "thirty" }));
|
|
514
|
+
expect(loadConfig().spawnRegisterTimeoutMs).toBe(30000);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("falls back to default for null", () => {
|
|
518
|
+
fs.writeFileSync(configFile, JSON.stringify({ spawnRegisterTimeoutMs: null }));
|
|
519
|
+
expect(loadConfig().spawnRegisterTimeoutMs).toBe(30000);
|
|
520
|
+
});
|
|
521
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { parseDashboardStarter } from "../dashboard-starter.js";
|
|
3
|
+
|
|
4
|
+
describe("parseDashboardStarter", () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.restoreAllMocks();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("returns Standalone when env is empty object", () => {
|
|
10
|
+
expect(parseDashboardStarter({})).toBe("Standalone");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns Standalone when DASHBOARD_STARTER is undefined", () => {
|
|
14
|
+
expect(parseDashboardStarter({ DASHBOARD_STARTER: undefined })).toBe("Standalone");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns Standalone when DASHBOARD_STARTER is empty string", () => {
|
|
18
|
+
expect(parseDashboardStarter({ DASHBOARD_STARTER: "" })).toBe("Standalone");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns Bridge for valid value", () => {
|
|
22
|
+
expect(parseDashboardStarter({ DASHBOARD_STARTER: "Bridge" })).toBe("Bridge");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns Standalone for valid value", () => {
|
|
26
|
+
expect(parseDashboardStarter({ DASHBOARD_STARTER: "Standalone" })).toBe("Standalone");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns Electron for valid value", () => {
|
|
30
|
+
expect(parseDashboardStarter({ DASHBOARD_STARTER: "Electron" })).toBe("Electron");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns Standalone and warns on invalid value", () => {
|
|
34
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
35
|
+
const result = parseDashboardStarter({ DASHBOARD_STARTER: "bogus" });
|
|
36
|
+
expect(result).toBe("Standalone");
|
|
37
|
+
expect(warnSpy).toHaveBeenCalledOnce();
|
|
38
|
+
expect(warnSpy.mock.calls[0]![0]).toContain("bogus");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -61,6 +61,30 @@ describe("spawnDetached", () => {
|
|
|
61
61
|
rmSync(path.dirname(logPath), { recursive: true, force: true });
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
// See change: fix-electron-extracted-jiti-and-stdio-capture.
|
|
65
|
+
it("redirects BOTH stdout and stderr to logFd when provided", async () => {
|
|
66
|
+
const logPath = tmpLog();
|
|
67
|
+
const fd = openSync(logPath, "a");
|
|
68
|
+
try {
|
|
69
|
+
const r = await spawnDetached({
|
|
70
|
+
cmd: process.execPath,
|
|
71
|
+
args: [
|
|
72
|
+
"-e",
|
|
73
|
+
"console.log('STDOUT-LINE'); process.stderr.write('STDERR-LINE'); setTimeout(() => process.exit(0), 100)",
|
|
74
|
+
],
|
|
75
|
+
logFd: fd,
|
|
76
|
+
});
|
|
77
|
+
expect(r.ok).toBe(true);
|
|
78
|
+
await new Promise((res) => r.process!.once("exit", res));
|
|
79
|
+
} finally {
|
|
80
|
+
try { closeSync(fd); } catch { /* ignore */ }
|
|
81
|
+
}
|
|
82
|
+
const content = readFileSync(logPath, "utf-8");
|
|
83
|
+
expect(content).toContain("STDOUT-LINE");
|
|
84
|
+
expect(content).toContain("STDERR-LINE");
|
|
85
|
+
rmSync(path.dirname(logPath), { recursive: true, force: true });
|
|
86
|
+
});
|
|
87
|
+
|
|
64
88
|
it("does not keep parent event loop alive (unref)", async () => {
|
|
65
89
|
// Can only check behaviour indirectly: the returned pid/process exist
|
|
66
90
|
// and the child is running detached. Lifecycle survival is covered by
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor core — section assignment, suggestion taxonomy, and the
|
|
3
|
+
* Decision-8 lint (every non-ok check has non-empty
|
|
4
|
+
* message/detail/suggestion). See change: doctor-rich-output.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
SECTION_OF,
|
|
9
|
+
SUGGESTIONS,
|
|
10
|
+
stampSectionsAndSuggestions,
|
|
11
|
+
type DoctorCheck,
|
|
12
|
+
type DoctorStatus,
|
|
13
|
+
} from "../doctor-core.js";
|
|
14
|
+
|
|
15
|
+
const ALL_CHECK_NAMES = Object.keys(SECTION_OF);
|
|
16
|
+
|
|
17
|
+
describe("SECTION_OF", () => {
|
|
18
|
+
it("maps every canonical check name to one of the five sections", () => {
|
|
19
|
+
const allowed = new Set(["runtime", "pi-tooling", "server", "setup", "diagnostics"]);
|
|
20
|
+
for (const name of ALL_CHECK_NAMES) {
|
|
21
|
+
expect(allowed.has(SECTION_OF[name])).toBe(true);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("covers all five sections (none empty)", () => {
|
|
26
|
+
const sections = new Set(Object.values(SECTION_OF));
|
|
27
|
+
for (const s of ["runtime", "pi-tooling", "server", "setup", "diagnostics"]) {
|
|
28
|
+
expect(sections.has(s as never)).toBe(true);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("SUGGESTIONS", () => {
|
|
34
|
+
it("returns undefined for status=ok across every check name", () => {
|
|
35
|
+
for (const name of ALL_CHECK_NAMES) {
|
|
36
|
+
const fn = SUGGESTIONS[name];
|
|
37
|
+
expect(fn).toBeDefined();
|
|
38
|
+
expect(fn?.("ok")).toBeUndefined();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns a non-empty string for status=error or warning when defined", () => {
|
|
43
|
+
for (const name of ALL_CHECK_NAMES) {
|
|
44
|
+
const fn = SUGGESTIONS[name];
|
|
45
|
+
// Electron is the only one that returns undefined even for non-ok
|
|
46
|
+
// (because today it never fails). Skip it.
|
|
47
|
+
if (name === "Electron") continue;
|
|
48
|
+
const w = fn?.("warning");
|
|
49
|
+
const e = fn?.("error");
|
|
50
|
+
expect(typeof w === "string" && w.length > 0).toBe(true);
|
|
51
|
+
expect(typeof e === "string" && e.length > 0).toBe(true);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("constrains suggestion text to the allowed Markdown subset", () => {
|
|
56
|
+
// Allowed: **bold**, single-backtick code, [text](url). Disallow: tables,
|
|
57
|
+
// headings, fenced blocks, raw HTML.
|
|
58
|
+
for (const name of ALL_CHECK_NAMES) {
|
|
59
|
+
const fn = SUGGESTIONS[name];
|
|
60
|
+
const candidates: (string | undefined)[] = [
|
|
61
|
+
fn?.("warning"),
|
|
62
|
+
fn?.("error"),
|
|
63
|
+
fn?.("error", undefined, "not-found"),
|
|
64
|
+
fn?.("error", undefined, "permission-denied"),
|
|
65
|
+
fn?.("error", undefined, "timeout"),
|
|
66
|
+
fn?.("error", undefined, "non-zero-exit"),
|
|
67
|
+
];
|
|
68
|
+
for (const s of candidates) {
|
|
69
|
+
if (!s) continue;
|
|
70
|
+
// No fenced code blocks.
|
|
71
|
+
expect(/```/.test(s)).toBe(false);
|
|
72
|
+
// No headings at line start.
|
|
73
|
+
expect(/^#{1,6}\s/m.test(s)).toBe(false);
|
|
74
|
+
// No raw HTML tags (closing, self-closing, or with attributes).
|
|
75
|
+
// Plain `<placeholder>` text is allowed (used as prose).
|
|
76
|
+
expect(/<\/[a-zA-Z]|<[a-zA-Z][^>]*\s+[^>]+>|<[a-zA-Z][^>]*\/>/.test(s)).toBe(false);
|
|
77
|
+
// Triple-asterisk or underline for bold not allowed.
|
|
78
|
+
expect(/\*\*\*|___/.test(s)).toBe(false);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("stampSectionsAndSuggestions (Decision 8 lint)", () => {
|
|
85
|
+
it("stamps section + suggestion on non-ok rows by name", () => {
|
|
86
|
+
const checks: DoctorCheck[] = [
|
|
87
|
+
{ name: "pi CLI", section: undefined as unknown as never, status: "error", message: "Not found", detail: "Searched PATH" },
|
|
88
|
+
{ name: "System Node.js", section: undefined as unknown as never, status: "ok", message: "v22 at /usr/bin/node" },
|
|
89
|
+
];
|
|
90
|
+
const out = stampSectionsAndSuggestions(checks);
|
|
91
|
+
expect(out[0].section).toBe("pi-tooling");
|
|
92
|
+
expect(out[0].suggestion).toBeDefined();
|
|
93
|
+
expect(out[1].section).toBe("runtime");
|
|
94
|
+
expect(out[1].suggestion).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("every non-ok row produced through stamping has non-empty message + detail + suggestion", () => {
|
|
98
|
+
const statuses: DoctorStatus[] = ["warning", "error"];
|
|
99
|
+
for (const name of ALL_CHECK_NAMES) {
|
|
100
|
+
// Electron suggestion is always undefined (decision-by-design); skip.
|
|
101
|
+
if (name === "Electron") continue;
|
|
102
|
+
for (const status of statuses) {
|
|
103
|
+
const checks: DoctorCheck[] = [
|
|
104
|
+
{
|
|
105
|
+
name,
|
|
106
|
+
section: undefined as unknown as never,
|
|
107
|
+
status,
|
|
108
|
+
message: "synthetic message",
|
|
109
|
+
detail: "synthetic detail",
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
const [stamped] = stampSectionsAndSuggestions(checks);
|
|
113
|
+
expect(stamped.message.length).toBeGreaterThan(0);
|
|
114
|
+
expect((stamped.detail ?? "").length).toBeGreaterThan(0);
|
|
115
|
+
expect((stamped.suggestion ?? "").length).toBeGreaterThan(0);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("does not overwrite an existing suggestion", () => {
|
|
121
|
+
const checks: DoctorCheck[] = [
|
|
122
|
+
{
|
|
123
|
+
name: "pi CLI",
|
|
124
|
+
section: "pi-tooling",
|
|
125
|
+
status: "error",
|
|
126
|
+
message: "x",
|
|
127
|
+
detail: "y",
|
|
128
|
+
suggestion: "custom",
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
const out = stampSectionsAndSuggestions(checks);
|
|
132
|
+
expect(out[0].suggestion).toBe("custom");
|
|
133
|
+
});
|
|
134
|
+
});
|