@blackbelt-technology/pi-agent-dashboard 0.4.5 → 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 +342 -267
- package/README.md +51 -2
- package/docs/architecture.md +266 -25
- 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-bus.test.ts +44 -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/__tests__/vcs-info-jj.test.ts +145 -0
- package/packages/extension/src/__tests__/{git-info.test.ts → vcs-info.test.ts} +6 -6
- package/packages/extension/src/bridge-context.ts +7 -0
- package/packages/extension/src/bridge.ts +142 -4
- package/packages/extension/src/command-handler.ts +6 -0
- package/packages/extension/src/markdown-image-inliner.ts +268 -0
- package/packages/extension/src/model-tracker.ts +35 -1
- package/packages/extension/src/prompt-bus.ts +4 -3
- 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 +6 -1
- package/packages/extension/src/vcs-info.ts +184 -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__/is-unread-trigger.test.ts +4 -2
- package/packages/server/src/__tests__/jj-routes.test.ts +93 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
- package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +114 -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-diff-vcs.test.ts +61 -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-restart.test.ts +4 -4
- 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 +22 -0
- package/packages/server/src/directory-service.ts +31 -0
- package/packages/server/src/event-wiring.ts +57 -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/openspec-tasks.ts +50 -19
- 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/jj-routes.ts +386 -0
- package/packages/server/src/routes/provider-auth-routes.ts +9 -0
- package/packages/server/src/routes/session-routes.ts +12 -3
- package/packages/server/src/routes/system-routes.ts +38 -1
- package/packages/server/src/server.ts +16 -9
- package/packages/server/src/session-bootstrap.ts +27 -12
- package/packages/server/src/session-diff.ts +118 -1
- 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__/platform-jj.test.ts +339 -0
- package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +18 -2
- package/packages/shared/src/bootstrap-install.ts +196 -2
- package/packages/shared/src/browser-protocol.ts +112 -1
- package/packages/shared/src/config.ts +29 -0
- package/packages/shared/src/dashboard-starter.ts +33 -0
- package/packages/shared/src/diff-types.ts +17 -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/jj.ts +405 -0
- package/packages/shared/src/platform/managed-node-path.ts +77 -0
- package/packages/shared/src/protocol.ts +60 -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 +19 -5
- package/packages/shared/src/tool-registry/strategies.ts +42 -0
- package/packages/shared/src/types.ts +91 -0
- package/packages/extension/src/git-info.ts +0 -55
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import { readFileSync, existsSync } from "node:fs";
|
|
6
6
|
import { resolve, relative, isAbsolute, sep as pathSep } from "node:path";
|
|
7
7
|
import * as git from "@blackbelt-technology/pi-dashboard-shared/platform/git.js";
|
|
8
|
-
import
|
|
8
|
+
import * as jj from "@blackbelt-technology/pi-dashboard-shared/platform/jj.js";
|
|
9
|
+
import type { DashboardEvent, JjState } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
9
10
|
import type { FileChangeEvent, FileDiffEntry, EditOperation } from "@blackbelt-technology/pi-dashboard-shared/diff-types.js";
|
|
10
11
|
import { isGitRepo } from "./git-operations.js";
|
|
11
12
|
|
|
@@ -176,3 +177,119 @@ export function enrichWithGitDiff(
|
|
|
176
177
|
|
|
177
178
|
return { enrichedFiles: enriched, isGitRepo: true };
|
|
178
179
|
}
|
|
180
|
+
|
|
181
|
+
// ── jj enrichment (regime-aware) ─────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Pure helper: pick the right diff base for a given jj state.
|
|
185
|
+
* - default workspace → `@-` (equivalent to `git diff HEAD`)
|
|
186
|
+
* - non-default → `fork_point(@, trunk())`
|
|
187
|
+
*
|
|
188
|
+
* Exported for unit testing without spawning jj.
|
|
189
|
+
*/
|
|
190
|
+
export function selectJjDiffBase(jjState: JjState | undefined): {
|
|
191
|
+
diffBase: string;
|
|
192
|
+
baseLabel: string;
|
|
193
|
+
} {
|
|
194
|
+
const workspace = jjState?.workspaceName;
|
|
195
|
+
if (!workspace || workspace === "default") {
|
|
196
|
+
return { diffBase: "@-", baseLabel: "@-" };
|
|
197
|
+
}
|
|
198
|
+
// Use the `..` range form (always-supported) instead of `fork_point()`
|
|
199
|
+
// (which changed signature across jj versions). `trunk()` returns the
|
|
200
|
+
// most-recent ancestor on main/master/trunk; the diff base is the
|
|
201
|
+
// single tip of trunk so that `--from <base> --to @` materializes the
|
|
202
|
+
// cumulative diff across every agent commit in this workspace.
|
|
203
|
+
return { diffBase: "trunk()", baseLabel: "trunk()" };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Enrich file entries with `jj diff` output, regime-aware. Runs
|
|
208
|
+
* `jj diff --from <baseRev> --to @ -- <path>` per file. Handles new
|
|
209
|
+
* files natively (no synthetic `/dev/null` fallback needed — jj
|
|
210
|
+
* reports new files in unified diff format directly).
|
|
211
|
+
*/
|
|
212
|
+
export function enrichWithJjDiff(
|
|
213
|
+
cwd: string,
|
|
214
|
+
files: FileDiffEntry[],
|
|
215
|
+
jjState: JjState | undefined,
|
|
216
|
+
): { enrichedFiles: FileDiffEntry[]; vcsKind: "jj"; diffBase: string; baseLabel: string } {
|
|
217
|
+
const { diffBase, baseLabel } = selectJjDiffBase(jjState);
|
|
218
|
+
const labelOverride = resolveBaseLabel(cwd, diffBase, baseLabel);
|
|
219
|
+
const enriched = files.map((file) => {
|
|
220
|
+
try {
|
|
221
|
+
const diff = jj.diffOr({
|
|
222
|
+
cwd,
|
|
223
|
+
fromRev: diffBase,
|
|
224
|
+
toRev: "@",
|
|
225
|
+
path: file.path,
|
|
226
|
+
}).trim();
|
|
227
|
+
if (diff) return { ...file, gitDiff: diff };
|
|
228
|
+
return file;
|
|
229
|
+
} catch {
|
|
230
|
+
return file;
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
return { enrichedFiles: enriched, vcsKind: "jj", diffBase, baseLabel: labelOverride };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Promote the abstract revset (e.g. `@-` or `fork_point(@, trunk())`) to
|
|
238
|
+
* a human-friendly bookmark name when one exists. Best effort — falls
|
|
239
|
+
* back to the abstract label if jj can't resolve it.
|
|
240
|
+
*/
|
|
241
|
+
function resolveBaseLabel(cwd: string, diffBase: string, fallback: string): string {
|
|
242
|
+
const result = jj.logRevset({
|
|
243
|
+
cwd,
|
|
244
|
+
revset: diffBase,
|
|
245
|
+
template: 'bookmarks ++ "\\n"',
|
|
246
|
+
});
|
|
247
|
+
if (!result.ok) return fallback;
|
|
248
|
+
const first = result.value.trim().split("\n")[0]?.trim();
|
|
249
|
+
if (first && first.length > 0 && first.length < 100) return first;
|
|
250
|
+
return fallback;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Unified dispatcher ──────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
export interface VcsEnrichmentResult {
|
|
256
|
+
enrichedFiles: FileDiffEntry[];
|
|
257
|
+
isGitRepo: boolean;
|
|
258
|
+
vcsKind?: "git" | "jj";
|
|
259
|
+
diffBase?: string;
|
|
260
|
+
baseLabel?: string;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Regime-aware dispatcher. When the session has `jjState.isJjRepo`,
|
|
265
|
+
* route through `enrichWithJjDiff` (which produces the cumulative diff
|
|
266
|
+
* for non-default workspaces). Otherwise fall back to the existing
|
|
267
|
+
* `enrichWithGitDiff` behavior unchanged — plain-git regime is byte-
|
|
268
|
+
* equivalent to the pre-change response shape (modulo the now-optional
|
|
269
|
+
* `vcsKind` field that older clients ignore).
|
|
270
|
+
*
|
|
271
|
+
* See change: add-jj-workspace-plugin.
|
|
272
|
+
*/
|
|
273
|
+
export function enrichWithVcsDiff(
|
|
274
|
+
cwd: string,
|
|
275
|
+
files: FileDiffEntry[],
|
|
276
|
+
jjState: JjState | undefined,
|
|
277
|
+
): VcsEnrichmentResult {
|
|
278
|
+
if (jjState?.isJjRepo) {
|
|
279
|
+
const result = enrichWithJjDiff(cwd, files, jjState);
|
|
280
|
+
return {
|
|
281
|
+
enrichedFiles: result.enrichedFiles,
|
|
282
|
+
isGitRepo: jjState.isColocated === true,
|
|
283
|
+
vcsKind: "jj",
|
|
284
|
+
diffBase: result.diffBase,
|
|
285
|
+
baseLabel: result.baseLabel,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const result = enrichWithGitDiff(cwd, files);
|
|
289
|
+
return {
|
|
290
|
+
...result,
|
|
291
|
+
vcsKind: result.isGitRepo ? "git" : undefined,
|
|
292
|
+
diffBase: result.isGitRepo ? "HEAD" : undefined,
|
|
293
|
+
baseLabel: result.isGitRepo ? "HEAD" : undefined,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import os from "node:os";
|
|
9
|
+
import { condenseForFirstMessage } from "@blackbelt-technology/pi-dashboard-shared/skill-block-parser.js";
|
|
9
10
|
|
|
10
11
|
export interface DiscoveredSession {
|
|
11
12
|
id: string;
|
|
@@ -53,15 +54,21 @@ function readSessionHeader(filePath: string): {
|
|
|
53
54
|
if (entry.type === "session_info" && entry.name) {
|
|
54
55
|
name = entry.name;
|
|
55
56
|
}
|
|
56
|
-
// Find first user message
|
|
57
|
+
// Find first user message. Skill invocations are stored as a
|
|
58
|
+
// `<skill name=...>...</skill>\n\nargs` envelope (~264 chars for typical
|
|
59
|
+
// absolute paths) which is longer than the 200-char firstMessage budget,
|
|
60
|
+
// so a naive .slice(0, 200) cuts the wrapper in half. condenseForFirstMessage
|
|
61
|
+
// returns the condensed slash form (`/skill:name args`) when the input
|
|
62
|
+
// matches the envelope, falling back to the raw slice otherwise.
|
|
63
|
+
// See change: render-skill-invocations-collapsibly.
|
|
57
64
|
if (!firstMessage && entry.type === "message" && entry.message?.role === "user") {
|
|
58
65
|
const msg = entry.message;
|
|
59
66
|
if (typeof msg.content === "string") {
|
|
60
|
-
firstMessage = msg.content
|
|
67
|
+
firstMessage = condenseForFirstMessage(msg.content, 200);
|
|
61
68
|
} else if (Array.isArray(msg.content)) {
|
|
62
69
|
for (const part of msg.content) {
|
|
63
70
|
if (part.type === "text" && part.text) {
|
|
64
|
-
firstMessage = part.text
|
|
71
|
+
firstMessage = condenseForFirstMessage(part.text, 200);
|
|
65
72
|
break;
|
|
66
73
|
}
|
|
67
74
|
}
|
|
@@ -8,6 +8,7 @@ import { join } from "node:path";
|
|
|
8
8
|
import os from "node:os";
|
|
9
9
|
import type { DashboardSession, SessionSource } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
10
10
|
import { type SessionMeta, metaPath, readSessionMeta, writeSessionMeta } from "@blackbelt-technology/pi-dashboard-shared/session-meta.js";
|
|
11
|
+
import { condenseForFirstMessage } from "@blackbelt-technology/pi-dashboard-shared/skill-block-parser.js";
|
|
11
12
|
import { extractSessionStats } from "./session-stats-reader.js";
|
|
12
13
|
|
|
13
14
|
function getSessionsDir(): string {
|
|
@@ -236,14 +237,15 @@ function readJsonlHeaderSync(filePath: string): { id: string; cwd: string; name?
|
|
|
236
237
|
const entry = JSON.parse(line);
|
|
237
238
|
if (entry.type === "session" && entry.id) header = entry;
|
|
238
239
|
if (entry.type === "session_info" && entry.name) name = entry.name;
|
|
240
|
+
// See change: render-skill-invocations-collapsibly.
|
|
239
241
|
if (!firstMessage && entry.type === "message" && entry.message?.role === "user") {
|
|
240
242
|
const msg = entry.message;
|
|
241
243
|
if (typeof msg.content === "string") {
|
|
242
|
-
firstMessage = msg.content
|
|
244
|
+
firstMessage = condenseForFirstMessage(msg.content, 200);
|
|
243
245
|
} else if (Array.isArray(msg.content)) {
|
|
244
246
|
for (const part of msg.content) {
|
|
245
247
|
if (part.type === "text" && part.text) {
|
|
246
|
-
firstMessage = part.text
|
|
248
|
+
firstMessage = condenseForFirstMessage(part.text, 200);
|
|
247
249
|
break;
|
|
248
250
|
}
|
|
249
251
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rolling NDJSON log of failed pi session spawn attempts.
|
|
3
|
+
*
|
|
4
|
+
* Location: ~/.pi/dashboard/sessions/spawn-failures.log
|
|
5
|
+
* Rotation: single-shot at 10 MB (renames to .log.1, overwrites any prior .log.1).
|
|
6
|
+
* Format: one JSON object per line, terminated by \n.
|
|
7
|
+
*
|
|
8
|
+
* See change: spawn-failure-diagnostics.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
appendFileSync,
|
|
12
|
+
existsSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
renameSync,
|
|
16
|
+
statSync,
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import type { PreflightReason } from "./spawn-preflight.js";
|
|
21
|
+
|
|
22
|
+
export interface SpawnFailureEntry {
|
|
23
|
+
/** ISO 8601 UTC timestamp. */
|
|
24
|
+
ts: string;
|
|
25
|
+
cwd: string;
|
|
26
|
+
strategy: string;
|
|
27
|
+
code: string;
|
|
28
|
+
message: string;
|
|
29
|
+
stderrTail?: string;
|
|
30
|
+
pid?: number;
|
|
31
|
+
reasons?: PreflightReason[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const LOG_MAX_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
35
|
+
const DEFAULT_LIMIT = 50;
|
|
36
|
+
const MAX_LIMIT = 500;
|
|
37
|
+
|
|
38
|
+
let _logDirOverride: string | null = null;
|
|
39
|
+
|
|
40
|
+
/** Override the log directory — for tests only. See change: spawn-failure-diagnostics. */
|
|
41
|
+
export function _setLogDirForTests(dir: string | null): void {
|
|
42
|
+
_logDirOverride = dir;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function logDir(): string {
|
|
46
|
+
return _logDirOverride ?? path.join(os.homedir(), ".pi", "dashboard", "sessions");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function logPath(): string {
|
|
50
|
+
return path.join(logDir(), "spawn-failures.log");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function logPath1(): string {
|
|
54
|
+
return path.join(logDir(), "spawn-failures.log.1");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Append a failure entry to the rolling log.
|
|
59
|
+
* Never throws — errors are caught and reported via `console.error`.
|
|
60
|
+
*/
|
|
61
|
+
export function appendSpawnFailure(entry: SpawnFailureEntry): void {
|
|
62
|
+
try {
|
|
63
|
+
const dir = logDir();
|
|
64
|
+
mkdirSync(dir, { recursive: true });
|
|
65
|
+
|
|
66
|
+
const filePath = logPath();
|
|
67
|
+
const line = JSON.stringify(entry) + "\n";
|
|
68
|
+
|
|
69
|
+
// Rotate if file exceeds threshold.
|
|
70
|
+
if (existsSync(filePath)) {
|
|
71
|
+
try {
|
|
72
|
+
const { size } = statSync(filePath);
|
|
73
|
+
if (size > LOG_MAX_BYTES) {
|
|
74
|
+
renameSync(filePath, logPath1());
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// If stat/rename fails, just write anyway.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
appendFileSync(filePath, line, "utf-8");
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error("[spawn-failure-log] Failed to append entry:", err);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read the last `limit` entries from the rolling log (both .log.1 and .log).
|
|
89
|
+
* Skips malformed lines. Returns [] when no log exists.
|
|
90
|
+
*/
|
|
91
|
+
export function readSpawnFailures(limit: number = DEFAULT_LIMIT): SpawnFailureEntry[] {
|
|
92
|
+
const effectiveLimit = Number.isNaN(limit) ? DEFAULT_LIMIT : Math.max(0, Math.min(limit, MAX_LIMIT));
|
|
93
|
+
if (effectiveLimit === 0) return [];
|
|
94
|
+
|
|
95
|
+
const lines: string[] = [];
|
|
96
|
+
|
|
97
|
+
// Read older log first, then newer.
|
|
98
|
+
for (const filePath of [logPath1(), logPath()]) {
|
|
99
|
+
if (!existsSync(filePath)) continue;
|
|
100
|
+
try {
|
|
101
|
+
const content = readFileSync(filePath, "utf-8");
|
|
102
|
+
lines.push(...content.split("\n").filter((l) => l.trim()));
|
|
103
|
+
} catch {
|
|
104
|
+
// Skip unreadable file.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Parse, skipping malformed lines.
|
|
109
|
+
const entries: SpawnFailureEntry[] = [];
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
try {
|
|
112
|
+
const obj = JSON.parse(line) as Record<string, unknown>;
|
|
113
|
+
// Require the minimum fields.
|
|
114
|
+
if (
|
|
115
|
+
typeof obj.ts === "string" &&
|
|
116
|
+
typeof obj.cwd === "string" &&
|
|
117
|
+
typeof obj.strategy === "string" &&
|
|
118
|
+
typeof obj.code === "string" &&
|
|
119
|
+
typeof obj.message === "string"
|
|
120
|
+
) {
|
|
121
|
+
entries.push(obj as unknown as SpawnFailureEntry);
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
// Skip malformed line.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Return last N in file order.
|
|
129
|
+
return entries.length <= effectiveLimit ? entries : entries.slice(entries.length - effectiveLimit);
|
|
130
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synchronous spawn preflight check.
|
|
3
|
+
*
|
|
4
|
+
* Runs before every `spawnPiSession` invocation to catch fast-fail conditions
|
|
5
|
+
* (bad cwd, missing binaries) without racing the spawn itself. All checks run
|
|
6
|
+
* regardless of earlier failures so the caller gets all reasons in one pass.
|
|
7
|
+
*
|
|
8
|
+
* The ToolResolver passed in MUST have `useLoginShell: false` — preflight
|
|
9
|
+
* must never spawn a login shell on the spawn-click hot path. If a resolver
|
|
10
|
+
* with `useLoginShell: true` is passed, the check still runs but a one-time
|
|
11
|
+
* warning is emitted.
|
|
12
|
+
*
|
|
13
|
+
* See change: spawn-failure-diagnostics.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, accessSync, statSync, constants } from "node:fs";
|
|
16
|
+
import { ToolResolver } from "@blackbelt-technology/pi-dashboard-shared/platform/binary-lookup.js";
|
|
17
|
+
|
|
18
|
+
export interface PreflightReason {
|
|
19
|
+
code: string;
|
|
20
|
+
message: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PreflightResult {
|
|
24
|
+
ok: boolean;
|
|
25
|
+
reasons: PreflightReason[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run all preflight checks for `cwd` and return the accumulated reasons.
|
|
30
|
+
* `ok` is `true` iff `reasons.length === 0`.
|
|
31
|
+
*
|
|
32
|
+
* @param deps.resolver - Must be constructed with `useLoginShell: false`.
|
|
33
|
+
* If omitted, a login-shell-disabled resolver is created automatically.
|
|
34
|
+
* Passing a resolver with `useLoginShell: true` violates the preflight
|
|
35
|
+
* contract; the function still runs but may call into the login shell.
|
|
36
|
+
*/
|
|
37
|
+
export function preflightSpawn(
|
|
38
|
+
cwd: string,
|
|
39
|
+
deps?: { resolver?: ToolResolver },
|
|
40
|
+
): PreflightResult {
|
|
41
|
+
const resolver = deps?.resolver ?? new ToolResolver({ processExecPath: process.execPath, useLoginShell: false });
|
|
42
|
+
|
|
43
|
+
const reasons: PreflightReason[] = [];
|
|
44
|
+
|
|
45
|
+
// 1. cwd exists
|
|
46
|
+
const cwdExists = existsSync(cwd);
|
|
47
|
+
if (!cwdExists) {
|
|
48
|
+
reasons.push({ code: "DIR_MISSING", message: `Directory does not exist: ${cwd}` });
|
|
49
|
+
// No point checking isDirectory / writable if it doesn't exist.
|
|
50
|
+
} else {
|
|
51
|
+
// 2. cwd is a directory
|
|
52
|
+
try {
|
|
53
|
+
const stat = statSync(cwd);
|
|
54
|
+
if (!stat.isDirectory()) {
|
|
55
|
+
reasons.push({ code: "DIR_NOT_DIRECTORY", message: `Path is not a directory: ${cwd}` });
|
|
56
|
+
}
|
|
57
|
+
} catch (err: any) {
|
|
58
|
+
reasons.push({ code: "DIR_NOT_DIRECTORY", message: `Cannot stat path: ${err.message}` });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 3. cwd is writable
|
|
62
|
+
try {
|
|
63
|
+
accessSync(cwd, constants.W_OK);
|
|
64
|
+
} catch {
|
|
65
|
+
reasons.push({ code: "DIR_NOT_WRITABLE", message: `Directory is not writable: ${cwd}` });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 4. pi resolves
|
|
70
|
+
const piCmd = resolver.resolvePi();
|
|
71
|
+
if (piCmd === null) {
|
|
72
|
+
reasons.push({ code: "PI_NOT_FOUND", message: "pi binary not found via managed install or system PATH" });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 5. node resolves
|
|
76
|
+
const nodeCmd = resolver.resolveNode();
|
|
77
|
+
if (nodeCmd === null) {
|
|
78
|
+
reasons.push({ code: "NODE_NOT_FOUND", message: "node binary not found via managed install or system PATH" });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { ok: reasons.length === 0, reasons };
|
|
82
|
+
}
|
|
@@ -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 {
|