@blackbelt-technology/pi-agent-dashboard 0.4.6 → 0.5.1
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 +50 -7
- package/docs/architecture.md +238 -23
- package/package.json +23 -13
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
- package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +68 -0
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
- package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
- 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__/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.test.ts +24 -1
- 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 +1 -1
- package/packages/extension/src/bridge.ts +169 -4
- package/packages/extension/src/command-handler.ts +65 -2
- package/packages/extension/src/flow-event-wiring.ts +1 -1
- package/packages/extension/src/markdown-image-inliner.ts +268 -0
- 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 +50 -2
- package/packages/extension/src/provider-register.ts +132 -8
- package/packages/extension/src/retry-tracker.ts +123 -0
- package/packages/extension/src/server-launcher.ts +18 -1
- package/packages/extension/src/session-sync.ts +15 -1
- package/packages/extension/src/usage-limit-orderer.ts +76 -0
- package/packages/server/package.json +6 -6
- 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__/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 +22 -4
- package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -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 +1 -1
- package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +154 -0
- 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__/has-openspec-dir.test.ts +64 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +83 -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-pi-process.test.ts +1 -1
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -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-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 +236 -0
- package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
- 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-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-routes.test.ts +12 -4
- package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +44 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +1 -1
- 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-correlation-token-integration.test.ts +91 -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 +250 -0
- package/packages/server/src/__tests__/spawn-token.test.ts +57 -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 +70 -24
- package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
- package/packages/server/src/browser-handlers/handler-context.ts +9 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +159 -18
- package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
- 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 +23 -2
- package/packages/server/src/directory-service.ts +31 -0
- package/packages/server/src/event-wiring.ts +105 -5
- package/packages/server/src/headless-pid-registry.ts +54 -5
- 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/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 +81 -15
- package/packages/server/src/pi-dev-version-check.ts +145 -0
- package/packages/server/src/pi-gateway.ts +10 -0
- package/packages/server/src/pi-version-skew.ts +12 -4
- package/packages/server/src/process-manager.ts +115 -21
- 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 +47 -0
- package/packages/server/src/routes/doctor-routes.ts +140 -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 +14 -1
- package/packages/server/src/routes/provider-routes.ts +4 -4
- package/packages/server/src/routes/system-routes.ts +38 -1
- package/packages/server/src/server.ts +85 -66
- package/packages/server/src/session-api.ts +54 -3
- package/packages/server/src/session-bootstrap.ts +27 -12
- package/packages/server/src/session-discovery.ts +11 -4
- package/packages/server/src/session-file-reader.ts +1 -1
- 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 +291 -0
- package/packages/server/src/spawn-token.ts +20 -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 +25 -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 +6 -3
- package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
- 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__/changelog-types.test.ts +78 -0
- 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__/node-spawn-jiti-contract.test.ts +56 -20
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +140 -9
- package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
- package/packages/shared/src/bootstrap-install.ts +197 -3
- package/packages/shared/src/browser-protocol.ts +155 -1
- package/packages/shared/src/changelog-types.ts +111 -0
- 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/platform/node-spawn.ts +29 -21
- package/packages/shared/src/protocol.ts +54 -2
- package/packages/shared/src/resolve-jiti.ts +62 -9
- 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 +33 -8
- package/packages/shared/src/tool-registry/strategies.ts +42 -0
- package/packages/shared/src/types.ts +64 -0
|
@@ -18,12 +18,13 @@ import { createPreferencesStore, type PreferencesStore } from "./preferences-sto
|
|
|
18
18
|
import { createMetaPersistence, type MetaPersistence } from "./meta-persistence.js";
|
|
19
19
|
import { createSessionOrderManager, type SessionOrderManager } from "./session-order-manager.js";
|
|
20
20
|
import { createPendingForkRegistry, type PendingForkRegistry } from "./pending-fork-registry.js";
|
|
21
|
+
import { createPendingClientCorrelations } from "./pending-client-correlations.js";
|
|
21
22
|
import { createPendingAttachRegistry } from "./pending-attach-registry.js";
|
|
22
23
|
import { createPendingResumeIntentRegistry } from "./pending-resume-intent-registry.js";
|
|
23
24
|
import { applyReattachPolicy } from "./reattach-placement.js";
|
|
24
25
|
|
|
25
26
|
// pending-load-manager removed — server loads sessions directly via DirectoryService
|
|
26
|
-
import { createDirectoryService, type DirectoryService } from "./directory-service.js";
|
|
27
|
+
import { createDirectoryService, isOpenSpecDataEmpty, type DirectoryService } from "./directory-service.js";
|
|
27
28
|
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
|
|
28
29
|
import { createTerminalGateway, type TerminalGateway } from "./terminal-gateway.js";
|
|
29
30
|
import { writePid, removePid } from "./server-pid.js";
|
|
@@ -45,10 +46,12 @@ import { registerGitRoutes } from "./routes/git-routes.js";
|
|
|
45
46
|
import { registerFileRoutes } from "./routes/file-routes.js";
|
|
46
47
|
import { registerOpenSpecRoutes } from "./routes/openspec-routes.js";
|
|
47
48
|
import { registerSystemRoutes } from "./routes/system-routes.js";
|
|
49
|
+
import { registerDoctorRoutes } from "./routes/doctor-routes.js";
|
|
48
50
|
import { registerProviderAuthRoutes } from "./routes/provider-auth-routes.js";
|
|
49
51
|
import { registerPackageRoutes } from "./routes/package-routes.js";
|
|
50
52
|
import { registerRecommendedRoutes, invalidateRecommendedCache } from "./routes/recommended-routes.js";
|
|
51
53
|
import { registerPiCoreRoutes } from "./routes/pi-core-routes.js";
|
|
54
|
+
import { registerPiChangelogRoutes } from "./routes/pi-changelog-routes.js";
|
|
52
55
|
import { PiCoreChecker } from "./pi-core-checker.js";
|
|
53
56
|
import { PiCoreUpdater } from "./pi-core-updater.js";
|
|
54
57
|
import { registerToolRoutes } from "./routes/tool-routes.js";
|
|
@@ -65,6 +68,7 @@ import { createEditorPidRegistry } from "./editor-pid-registry.js";
|
|
|
65
68
|
import { registerEditorRoutes } from "./routes/editor-routes.js";
|
|
66
69
|
import { registerKnownServersRoutes } from "./routes/known-servers-routes.js";
|
|
67
70
|
import { registerPluginConfigRoutes } from "./routes/plugin-config-routes.js";
|
|
71
|
+
import { writeConfigPartial } from "./config-api.js";
|
|
68
72
|
import { loadServerEntries, discoverPlugins, getPluginStatusStore } from "@blackbelt-technology/dashboard-plugin-runtime/server";
|
|
69
73
|
import { createServerPluginContext } from "@blackbelt-technology/dashboard-plugin-runtime/server";
|
|
70
74
|
import { getPluginConfig as getPluginConfigFromFile } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
@@ -143,10 +147,6 @@ export interface PostInstallRepairDeps {
|
|
|
143
147
|
browserGateway: { broadcastToAll(msg: ServerToBrowserMessage): void };
|
|
144
148
|
}
|
|
145
149
|
|
|
146
|
-
function isOpenSpecDataEmpty(d: OpenSpecData | undefined): boolean {
|
|
147
|
-
if (!d) return true;
|
|
148
|
-
return !d.initialized && (!d.changes || d.changes.length === 0);
|
|
149
|
-
}
|
|
150
150
|
|
|
151
151
|
/**
|
|
152
152
|
* Centralized post-install repair work fired on every `installing → ready`
|
|
@@ -272,6 +272,10 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
272
272
|
const metaPersistence = createMetaPersistence();
|
|
273
273
|
const sessionOrderManager = createSessionOrderManager(preferencesStore);
|
|
274
274
|
const pendingForkRegistry = createPendingForkRegistry();
|
|
275
|
+
// Maps spawnToken → originating browser requestId. Surfaced as
|
|
276
|
+
// session_added.spawnRequestId so the client can auto-select / dismiss
|
|
277
|
+
// its placeholder by exact correlation. See change: spawn-correlation-token.
|
|
278
|
+
const pendingClientCorrelations = createPendingClientCorrelations();
|
|
275
279
|
|
|
276
280
|
// Restore sessions from per-session .meta.json files (scans ~/.pi/agent/sessions/)
|
|
277
281
|
const scanResult = scanAllSessions();
|
|
@@ -475,7 +479,11 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
475
479
|
knownSessionIds.add(s.id);
|
|
476
480
|
}
|
|
477
481
|
|
|
478
|
-
const directoryService = createDirectoryService(
|
|
482
|
+
const directoryService = createDirectoryService(
|
|
483
|
+
preferencesStore,
|
|
484
|
+
sessionManager,
|
|
485
|
+
config.openspec,
|
|
486
|
+
);
|
|
479
487
|
|
|
480
488
|
// mDNS peer discovery state
|
|
481
489
|
let mdnsBrowser: DashboardBrowser | null = null;
|
|
@@ -524,7 +532,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
524
532
|
},
|
|
525
533
|
});
|
|
526
534
|
|
|
527
|
-
const browserGateway = createBrowserGateway(sessionManager, eventStore, piGateway, undefined, pendingForkRegistry, sessionOrderManager, preferencesStore, directoryService, terminalManager, pendingDashboardSpawns, config.maxWsBufferBytes, pendingAttachRegistry, pendingResumeIntents);
|
|
535
|
+
const browserGateway = createBrowserGateway(sessionManager, eventStore, piGateway, undefined, pendingForkRegistry, sessionOrderManager, preferencesStore, directoryService, terminalManager, pendingDashboardSpawns, config.maxWsBufferBytes, pendingAttachRegistry, pendingResumeIntents, pendingClientCorrelations);
|
|
528
536
|
|
|
529
537
|
// Resolve package version once at startup
|
|
530
538
|
const __require = createRequire(import.meta.url);
|
|
@@ -560,10 +568,13 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
560
568
|
pendingDashboardSpawns,
|
|
561
569
|
pendingAttachRegistry,
|
|
562
570
|
viewedSessionTracker: browserGateway.viewedSessionTracker,
|
|
571
|
+
pendingClientCorrelations,
|
|
563
572
|
});
|
|
564
573
|
|
|
565
574
|
// Auto-shutdown idle timer
|
|
566
|
-
|
|
575
|
+
// Active terminals keep the server alive even when no pi sessions are
|
|
576
|
+
// attached. See change: fix-terminal-half-height-dual-mount.
|
|
577
|
+
const idleTimer = createIdleTimer(config, piGateway, () => terminalManager.list().length > 0);
|
|
567
578
|
|
|
568
579
|
const fastify = Fastify({
|
|
569
580
|
logger: false,
|
|
@@ -689,6 +700,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
689
700
|
bootstrapState,
|
|
690
701
|
bootstrapQueue,
|
|
691
702
|
pendingResumeIntents,
|
|
703
|
+
pendingAttachRegistry,
|
|
692
704
|
});
|
|
693
705
|
|
|
694
706
|
// Register route modules
|
|
@@ -709,7 +721,9 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
709
721
|
if (data) browserGateway.broadcastToAll({ type: "openspec_update", cwd, data });
|
|
710
722
|
},
|
|
711
723
|
});
|
|
712
|
-
registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version: pkgVersion, directoryService, piGateway });
|
|
724
|
+
registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version: pkgVersion, directoryService, piGateway, bootstrapState });
|
|
725
|
+
// GET /api/doctor — see change: doctor-rich-output (task 4.2). Auth-gated identically to /api/config.
|
|
726
|
+
registerDoctorRoutes(fastify);
|
|
713
727
|
registerToolRoutes(fastify, { registry: getDefaultRegistry(), networkGuard });
|
|
714
728
|
registerJjRoutes(fastify, { browserGateway, pendingAttachRegistry, networkGuard });
|
|
715
729
|
|
|
@@ -720,11 +734,11 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
720
734
|
bootstrapState,
|
|
721
735
|
networkGuard,
|
|
722
736
|
triggerUpgradePi: async () => {
|
|
723
|
-
const packages = ["@
|
|
737
|
+
const packages = ["@earendil-works/pi-coding-agent"];
|
|
724
738
|
bootstrapState.setLastInstallPackages(packages);
|
|
725
739
|
bootstrapState.set({
|
|
726
740
|
status: "installing",
|
|
727
|
-
progress: { step: "@
|
|
741
|
+
progress: { step: "@earendil-works/pi-coding-agent", output: "starting upgrade…" },
|
|
728
742
|
error: undefined,
|
|
729
743
|
});
|
|
730
744
|
try {
|
|
@@ -778,7 +792,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
778
792
|
const prev = bootstrapState.getLastInstallPackages();
|
|
779
793
|
const packages = prev.length > 0
|
|
780
794
|
? prev
|
|
781
|
-
: ["@
|
|
795
|
+
: ["@earendil-works/pi-coding-agent", "@fission-ai/openspec", "tsx"];
|
|
782
796
|
bootstrapState.set({
|
|
783
797
|
status: "installing",
|
|
784
798
|
progress: { step: "retry", output: `restarting install (${packages.length} pkg${packages.length === 1 ? "" : "s"})…` },
|
|
@@ -899,6 +913,8 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
899
913
|
message: event.message,
|
|
900
914
|
});
|
|
901
915
|
});
|
|
916
|
+
registerPiChangelogRoutes(fastify, { bootstrapState });
|
|
917
|
+
|
|
902
918
|
registerPiCoreRoutes(fastify, {
|
|
903
919
|
piCoreChecker,
|
|
904
920
|
piCoreUpdater,
|
|
@@ -1076,6 +1092,63 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
1076
1092
|
|
|
1077
1093
|
piGateway.start(config.piPort);
|
|
1078
1094
|
|
|
1095
|
+
// Load plugin server entries BEFORE fastify.listen() so plugins can
|
|
1096
|
+
// register routes. Fastify rejects route registration after listen().
|
|
1097
|
+
// Failure-isolated per-plugin via loader; awaited so all routes are
|
|
1098
|
+
// mounted before requests can arrive.
|
|
1099
|
+
try {
|
|
1100
|
+
await loadServerEntries({
|
|
1101
|
+
isEnabled: (pluginId) => {
|
|
1102
|
+
const cfg = loadConfig();
|
|
1103
|
+
const pluginCfg = getPluginConfigFromFile(cfg, pluginId) as Record<string, unknown>;
|
|
1104
|
+
return pluginCfg.enabled !== false;
|
|
1105
|
+
},
|
|
1106
|
+
createContext: (plugin) => createServerPluginContext(
|
|
1107
|
+
{
|
|
1108
|
+
fastify,
|
|
1109
|
+
sessionManager: {
|
|
1110
|
+
listActive: () => sessionManager.listActive(),
|
|
1111
|
+
listAll: () => sessionManager.listAll(),
|
|
1112
|
+
getSession: (id: string) => sessionManager.get(id),
|
|
1113
|
+
},
|
|
1114
|
+
eventStore: {
|
|
1115
|
+
getEvents: (sessionId) => eventStore.getEvents(sessionId, 0),
|
|
1116
|
+
getLatestEvent: (sessionId) => {
|
|
1117
|
+
const events = eventStore.getEvents(sessionId, 0);
|
|
1118
|
+
return events.length > 0 ? events[events.length - 1] : undefined;
|
|
1119
|
+
},
|
|
1120
|
+
},
|
|
1121
|
+
broadcastToSubscribers: (msg) => browserGateway.broadcast(msg as any),
|
|
1122
|
+
registerPiHandler: (_type, _handler) => {},
|
|
1123
|
+
registerBrowserHandler: (_type, _handler) => {},
|
|
1124
|
+
getPluginConfig: (id) => {
|
|
1125
|
+
const cfg = loadConfig();
|
|
1126
|
+
return getPluginConfigFromFile(cfg, id);
|
|
1127
|
+
},
|
|
1128
|
+
updatePluginConfig: async (id, partial) => {
|
|
1129
|
+
const cfg = loadConfig();
|
|
1130
|
+
const current = getPluginConfigFromFile(cfg, id);
|
|
1131
|
+
const merged = { ...current, ...partial };
|
|
1132
|
+
let rawConfig: Record<string, unknown> = {};
|
|
1133
|
+
try {
|
|
1134
|
+
const raw = (await import('node:fs')).default.readFileSync(CONFIG_FILE, 'utf-8');
|
|
1135
|
+
rawConfig = JSON.parse(raw);
|
|
1136
|
+
} catch { /* start fresh */ }
|
|
1137
|
+
rawConfig.plugins = { ...(rawConfig.plugins as Record<string, unknown> ?? {}), [id]: merged };
|
|
1138
|
+
const fs = (await import('node:fs')).default;
|
|
1139
|
+
const tmpFile = CONFIG_FILE + '.tmp.' + process.pid;
|
|
1140
|
+
fs.writeFileSync(tmpFile, JSON.stringify(rawConfig, null, 2) + '\n');
|
|
1141
|
+
fs.renameSync(tmpFile, CONFIG_FILE);
|
|
1142
|
+
browserGateway.broadcast({ type: 'plugin_config_update', id, config: merged } as any);
|
|
1143
|
+
},
|
|
1144
|
+
},
|
|
1145
|
+
plugin.manifest.id,
|
|
1146
|
+
),
|
|
1147
|
+
});
|
|
1148
|
+
} catch (err) {
|
|
1149
|
+
console.error('[plugin-loader] Unexpected error during pre-listen load:', err);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1079
1152
|
fastify.server.on("upgrade", (request, socket, head) => {
|
|
1080
1153
|
// Access check for WebSocket upgrades
|
|
1081
1154
|
const remoteAddress = request.socket.remoteAddress || "";
|
|
@@ -1159,60 +1232,6 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
1159
1232
|
// Discover sessions and start OpenSpec polling (async, non-blocking)
|
|
1160
1233
|
discoverAndBroadcastSessions({ sessionManager, browserGateway, directoryService });
|
|
1161
1234
|
|
|
1162
|
-
// Load plugin server entries (non-blocking; failures isolated per plugin)
|
|
1163
|
-
loadServerEntries({
|
|
1164
|
-
isEnabled: (pluginId) => {
|
|
1165
|
-
const cfg = loadConfig();
|
|
1166
|
-
const pluginCfg = getPluginConfigFromFile(cfg, pluginId) as Record<string, unknown>;
|
|
1167
|
-
return pluginCfg.enabled !== false; // default: enabled
|
|
1168
|
-
},
|
|
1169
|
-
createContext: (plugin) => createServerPluginContext(
|
|
1170
|
-
{
|
|
1171
|
-
fastify,
|
|
1172
|
-
sessionManager: {
|
|
1173
|
-
listActive: () => sessionManager.listActive(),
|
|
1174
|
-
listAll: () => sessionManager.listAll(),
|
|
1175
|
-
getSession: (id: string) => sessionManager.get(id),
|
|
1176
|
-
},
|
|
1177
|
-
eventStore: {
|
|
1178
|
-
getEvents: (sessionId) => eventStore.getEvents(sessionId, 0),
|
|
1179
|
-
getLatestEvent: (sessionId) => {
|
|
1180
|
-
const events = eventStore.getEvents(sessionId, 0);
|
|
1181
|
-
return events.length > 0 ? events[events.length - 1] : undefined;
|
|
1182
|
-
},
|
|
1183
|
-
},
|
|
1184
|
-
broadcastToSubscribers: (msg) => browserGateway.broadcast(msg as any),
|
|
1185
|
-
// Plugin pi/browser handler registration — stub for now;
|
|
1186
|
-
// full dynamic handler registration requires a registry refactor
|
|
1187
|
-
// tracked in extract-*-as-plugin changes.
|
|
1188
|
-
registerPiHandler: (_type, _handler) => {},
|
|
1189
|
-
registerBrowserHandler: (_type, _handler) => {},
|
|
1190
|
-
getPluginConfig: (id) => {
|
|
1191
|
-
const cfg = loadConfig();
|
|
1192
|
-
return getPluginConfigFromFile(cfg, id);
|
|
1193
|
-
},
|
|
1194
|
-
updatePluginConfig: async (id, partial) => {
|
|
1195
|
-
// Inline partial write (reuses CONFIG_FILE path from shared config)
|
|
1196
|
-
const cfg = loadConfig();
|
|
1197
|
-
const current = getPluginConfigFromFile(cfg, id);
|
|
1198
|
-
const merged = { ...current, ...partial };
|
|
1199
|
-
let rawConfig: Record<string, unknown> = {};
|
|
1200
|
-
try {
|
|
1201
|
-
const raw = (await import('node:fs')).default.readFileSync(CONFIG_FILE, 'utf-8');
|
|
1202
|
-
rawConfig = JSON.parse(raw);
|
|
1203
|
-
} catch { /* start fresh */ }
|
|
1204
|
-
rawConfig.plugins = { ...(rawConfig.plugins as Record<string, unknown> ?? {}), [id]: merged };
|
|
1205
|
-
const fs = (await import('node:fs')).default;
|
|
1206
|
-
const tmpFile = CONFIG_FILE + '.tmp.' + process.pid;
|
|
1207
|
-
fs.writeFileSync(tmpFile, JSON.stringify(rawConfig, null, 2) + '\n');
|
|
1208
|
-
fs.renameSync(tmpFile, CONFIG_FILE);
|
|
1209
|
-
browserGateway.broadcast({ type: 'plugin_config_update', id, config: merged } as any);
|
|
1210
|
-
},
|
|
1211
|
-
},
|
|
1212
|
-
plugin.manifest.id,
|
|
1213
|
-
),
|
|
1214
|
-
}).catch((err) => console.error('[plugin-loader] Unexpected error:', err));
|
|
1215
|
-
|
|
1216
1235
|
// Auto-register plugin bridge entries
|
|
1217
1236
|
const discoveredPlugins = discoverPlugins();
|
|
1218
1237
|
const pluginsWithBridges = discoveredPlugins
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* These expose WebSocket-only operations as HTTP endpoints
|
|
4
4
|
* for use by skills, scripts, and external tooling.
|
|
5
5
|
*/
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
6
7
|
import type { FastifyInstance } from "fastify";
|
|
7
8
|
import type { SessionManager } from "./memory-session-manager.js";
|
|
8
9
|
import type { PiGateway } from "./pi-gateway.js";
|
|
@@ -15,6 +16,7 @@ import type { PendingResumeIntentRegistry } from "./pending-resume-intent-regist
|
|
|
15
16
|
import type { BootstrapStateStore } from "./bootstrap-state.js";
|
|
16
17
|
import type { BootstrapQueue } from "./bootstrap-queue.js";
|
|
17
18
|
import { attachRenameTarget, detachShouldClearName } from "./proposal-attach-naming.js";
|
|
19
|
+
import { FORK_DEGRADED_TO_NEW_MESSAGE, FORK_DEGRADED_TO_NEW_CODE } from "./browser-handlers/session-action-handler.js";
|
|
18
20
|
|
|
19
21
|
export interface SessionApiDeps {
|
|
20
22
|
sessionManager: SessionManager;
|
|
@@ -36,6 +38,13 @@ export interface SessionApiDeps {
|
|
|
36
38
|
* See change: preserve-session-order-on-reboot.
|
|
37
39
|
*/
|
|
38
40
|
pendingResumeIntents?: PendingResumeIntentRegistry;
|
|
41
|
+
/**
|
|
42
|
+
* Optional pending-attach registry. When provided, the resume endpoint's
|
|
43
|
+
* fork-empty-session degradation path inherits the parent's
|
|
44
|
+
* `attachedProposal` for the new spawn.
|
|
45
|
+
* See change: fix-fork-empty-session-silent-timeout.
|
|
46
|
+
*/
|
|
47
|
+
pendingAttachRegistry?: import("./pending-attach-registry.js").PendingAttachRegistry;
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
type IdParams = { Params: { id: string } };
|
|
@@ -48,7 +57,7 @@ function getSessionOrFail(sessionManager: SessionManager, id: string): { session
|
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
export function registerSessionApi(fastify: FastifyInstance, deps: SessionApiDeps) {
|
|
51
|
-
const { sessionManager, piGateway, browserGateway, pendingForkRegistry, pendingDashboardSpawns, bootstrapState, bootstrapQueue, pendingResumeIntents } = deps;
|
|
60
|
+
const { sessionManager, piGateway, browserGateway, pendingForkRegistry, pendingDashboardSpawns, bootstrapState, bootstrapQueue, pendingResumeIntents, pendingAttachRegistry } = deps;
|
|
52
61
|
|
|
53
62
|
/**
|
|
54
63
|
* Gate pi-dependent operations on bootstrap status. Returns:
|
|
@@ -282,8 +291,44 @@ export function registerSessionApi(fastify: FastifyInstance, deps: SessionApiDep
|
|
|
282
291
|
reply.code(409);
|
|
283
292
|
return { success: false, error: "session is already being resumed" } satisfies ApiResponse;
|
|
284
293
|
}
|
|
285
|
-
|
|
286
|
-
|
|
294
|
+
// Fork preflight: silent-degrade when the source has no on-disk JSONL.
|
|
295
|
+
// Mirrors the WS-handler logic. See change:
|
|
296
|
+
// fix-fork-empty-session-silent-timeout.
|
|
297
|
+
if (mode === "fork" && !existsSync(session.sessionFile)) {
|
|
298
|
+
// Inherit attachedProposal from parent.
|
|
299
|
+
if (session.attachedProposal && pendingAttachRegistry) {
|
|
300
|
+
pendingAttachRegistry.enqueue(session.cwd, session.attachedProposal);
|
|
301
|
+
}
|
|
302
|
+
const degradeConfig = loadConfig();
|
|
303
|
+
const degradeResult = await spawnPiSession(session.cwd, {
|
|
304
|
+
strategy: degradeConfig.spawnStrategy,
|
|
305
|
+
});
|
|
306
|
+
if (degradeResult.process && degradeResult.pid) {
|
|
307
|
+
browserGateway.headlessPidRegistry.register(
|
|
308
|
+
degradeResult.pid,
|
|
309
|
+
session.cwd,
|
|
310
|
+
degradeResult.process,
|
|
311
|
+
degradeResult.spawnToken,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
if (degradeResult.dashboardSpawned && degradeResult.success) {
|
|
315
|
+
pendingDashboardSpawns?.set(
|
|
316
|
+
session.cwd,
|
|
317
|
+
(pendingDashboardSpawns?.get(session.cwd) ?? 0) + 1,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
if (!degradeResult.success) {
|
|
321
|
+
reply.code(500);
|
|
322
|
+
return {
|
|
323
|
+
success: false,
|
|
324
|
+
error: degradeResult.message,
|
|
325
|
+
} satisfies ApiResponse;
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
success: true,
|
|
329
|
+
data: { message: FORK_DEGRADED_TO_NEW_MESSAGE },
|
|
330
|
+
code: FORK_DEGRADED_TO_NEW_CODE,
|
|
331
|
+
} satisfies ApiResponse<{ message: string }>;
|
|
287
332
|
}
|
|
288
333
|
// Tag the user-resume intent BEFORE spawning. REST resume always
|
|
289
334
|
// uses "front" placement — the only "keep" path is drag-to-resume
|
|
@@ -297,6 +342,12 @@ export function registerSessionApi(fastify: FastifyInstance, deps: SessionApiDep
|
|
|
297
342
|
mode,
|
|
298
343
|
strategy: config.spawnStrategy,
|
|
299
344
|
});
|
|
345
|
+
// Fork bookkeeping uses the spawn token (not cwd) so two concurrent
|
|
346
|
+
// forks in the same cwd correlate correctly. See change:
|
|
347
|
+
// spawn-correlation-token.
|
|
348
|
+
if (mode === "fork" && pendingForkRegistry && spawnResult.spawnToken) {
|
|
349
|
+
pendingForkRegistry.recordFork(spawnResult.spawnToken, id);
|
|
350
|
+
}
|
|
300
351
|
if (spawnResult.dashboardSpawned && spawnResult.success) {
|
|
301
352
|
pendingDashboardSpawns?.set(session.cwd, (pendingDashboardSpawns?.get(session.cwd) ?? 0) + 1);
|
|
302
353
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { SessionManager } from "./memory-session-manager.js";
|
|
6
6
|
import type { BrowserGateway } from "./browser-gateway.js";
|
|
7
|
-
import type
|
|
7
|
+
import { isOpenSpecDataEmpty, type DirectoryService } from "./directory-service.js";
|
|
8
8
|
import { extractSessionStats } from "./session-stats-reader.js";
|
|
9
9
|
|
|
10
10
|
export interface SessionBootstrapDeps {
|
|
@@ -75,20 +75,35 @@ export async function discoverAndBroadcastSessions(deps: SessionBootstrapDeps):
|
|
|
75
75
|
|
|
76
76
|
// Initial OpenSpec poll for all known directories.
|
|
77
77
|
//
|
|
78
|
-
//
|
|
79
|
-
// (spawnSync per change) — on Windows with many active changes
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// openspec data populates in the background and pushes `openspec_update`
|
|
84
|
-
// broadcasts to browsers as each directory finishes.
|
|
78
|
+
// Fire-and-forget: `refreshOpenSpec` / `pollOpenSpec` is synchronous internally
|
|
79
|
+
// (spawnSync per change) — on Windows with many active changes and multiple
|
|
80
|
+
// pinned directories this can block the event loop for minutes, making the
|
|
81
|
+
// HTTP server unresponsive during startup. We intentionally do NOT await it
|
|
82
|
+
// here so HTTP + WebSocket startup completes immediately.
|
|
85
83
|
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
84
|
+
// After each directory's poll completes, broadcast `openspec_update` to all
|
|
85
|
+
// connected browsers if the prior cache was empty/undefined or the polled
|
|
86
|
+
// data differs from prior — mirroring the proven `runPostInstallRepair`
|
|
87
|
+
// pattern in `server.ts`. This is what unblocks cold-boot Electron clients
|
|
88
|
+
// that connected before the cache was hot.
|
|
89
|
+
//
|
|
90
|
+
// A proper fix for the slow `spawnSync` path is to migrate the openspec
|
|
91
|
+
// Recipe to async spawn; tracked separately. See change:
|
|
92
|
+
// consolidate-tool-resolution. This change covers the broadcast wiring only.
|
|
93
|
+
// See change: fix-cold-boot-openspec-protocol.
|
|
88
94
|
void Promise.all(
|
|
89
95
|
directoryService.knownDirectories().map(async (cwd) => {
|
|
90
|
-
try {
|
|
91
|
-
|
|
96
|
+
try {
|
|
97
|
+
const prior = directoryService.getOpenSpecData(cwd);
|
|
98
|
+
const fresh = await directoryService.refreshOpenSpec(cwd);
|
|
99
|
+
const priorEmpty = isOpenSpecDataEmpty(prior);
|
|
100
|
+
const dataDiffers = JSON.stringify(prior) !== JSON.stringify(fresh);
|
|
101
|
+
if (priorEmpty || dataDiffers) {
|
|
102
|
+
browserGateway.broadcastToAll({ type: "openspec_update", cwd, data: fresh });
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.error(`[dashboard] initial openspec poll failed for ${cwd}:`, err);
|
|
106
|
+
}
|
|
92
107
|
}),
|
|
93
108
|
);
|
|
94
109
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Standalone session discovery — lists pi sessions for a given cwd
|
|
3
|
-
* without requiring @
|
|
3
|
+
* without requiring @earendil-works/pi-coding-agent.
|
|
4
4
|
* Reads session JSONL files from ~/.pi/agent/sessions/<encoded-cwd>/.
|
|
5
5
|
*/
|
|
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
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Standalone JSONL session file reader.
|
|
3
|
-
* Reads pi session files without requiring @
|
|
3
|
+
* Reads pi session files without requiring @earendil-works/pi-coding-agent.
|
|
4
4
|
* Falls back to linear entry order (no tree branching support).
|
|
5
5
|
*/
|
|
6
6
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
@@ -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
|
+
}
|