@blackbelt-technology/pi-agent-dashboard 0.5.1 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +26 -5
- package/README.md +30 -0
- package/docs/architecture.md +129 -1
- package/package.json +6 -6
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +10 -8
- package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
- package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
- package/packages/extension/src/bridge-context.ts +67 -3
- package/packages/extension/src/bridge.ts +20 -8
- package/packages/extension/src/command-handler.ts +36 -13
- package/packages/extension/src/prompt-expander.ts +74 -63
- package/packages/extension/src/server-launcher.ts +31 -70
- package/packages/extension/src/slash-dispatch.ts +123 -0
- package/packages/server/bin/pi-dashboard.mjs +84 -0
- package/packages/server/package.json +6 -5
- package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +12 -18
- package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
- package/packages/server/src/__tests__/directory-service.test.ts +1 -1
- package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
- package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
- package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
- package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
- package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
- package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
- package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
- package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
- package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
- package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
- package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
- package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
- package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
- package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
- package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
- package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
- package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
- package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
- package/packages/server/src/auth-plugin.ts +3 -0
- package/packages/server/src/bootstrap-state.ts +10 -0
- package/packages/server/src/browser-gateway.ts +15 -7
- package/packages/server/src/browser-handlers/session-action-handler.ts +30 -4
- package/packages/server/src/cli.ts +61 -81
- package/packages/server/src/config-api.ts +14 -2
- package/packages/server/src/directory-service.ts +106 -4
- package/packages/server/src/event-wiring.ts +31 -1
- package/packages/server/src/headless-pid-registry.ts +299 -41
- package/packages/server/src/legacy-pi-cleanup.ts +151 -0
- package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
- package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
- package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
- package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
- package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
- package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
- package/packages/server/src/model-proxy/api-key-store.ts +87 -0
- package/packages/server/src/model-proxy/auth-gate.ts +116 -0
- package/packages/server/src/model-proxy/concurrency.ts +76 -0
- package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
- package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
- package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
- package/packages/server/src/model-proxy/convert/index.ts +8 -0
- package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
- package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
- package/packages/server/src/model-proxy/convert/types.ts +70 -0
- package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
- package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
- package/packages/server/src/model-proxy/internal-registry.ts +157 -0
- package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
- package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
- package/packages/server/src/model-proxy/request-log.ts +53 -0
- package/packages/server/src/model-proxy/streamer.ts +59 -0
- package/packages/server/src/openspec-group-store.ts +490 -0
- package/packages/server/src/process-manager.ts +128 -0
- package/packages/server/src/provider-auth-storage.ts +29 -47
- package/packages/server/src/restart-helper.ts +17 -16
- package/packages/server/src/routes/bootstrap-routes.ts +37 -0
- package/packages/server/src/routes/jj-routes.ts +3 -0
- package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
- package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
- package/packages/server/src/routes/model-proxy-routes.ts +330 -0
- package/packages/server/src/routes/openspec-group-routes.ts +231 -0
- package/packages/server/src/routes/provider-auth-routes.ts +3 -0
- package/packages/server/src/routes/provider-routes.ts +24 -1
- package/packages/server/src/routes/system-routes.ts +44 -2
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
- package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
- package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
- package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
- package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
- package/packages/server/src/server.ts +178 -2
- package/packages/server/src/session-api.ts +9 -1
- package/packages/server/src/tunnel-watchdog.ts +230 -0
- package/packages/server/src/tunnel.ts +5 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
- package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
- package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
- package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
- package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
- package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
- package/packages/shared/src/bootstrap-install.ts +1 -1
- package/packages/shared/src/browser-protocol.ts +27 -0
- package/packages/shared/src/config.ts +172 -2
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
- package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
- package/packages/shared/src/platform/binary-lookup.ts +204 -0
- package/packages/shared/src/platform/node-spawn.ts +42 -5
- package/packages/shared/src/protocol.ts +19 -1
- package/packages/shared/src/recommended-extensions.ts +18 -0
- package/packages/shared/src/rest-api.ts +219 -1
- package/packages/shared/src/server-launcher.ts +277 -0
- package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
- package/packages/shared/src/types.ts +55 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -184
- package/packages/shared/src/resolve-jiti.ts +0 -155
|
@@ -35,6 +35,7 @@ import { discoverAndBroadcastSessions } from "./session-bootstrap.js";
|
|
|
35
35
|
import { scanAllSessions } from "./session-scanner.js";
|
|
36
36
|
import { needsMigration, runMigration } from "./migrate-persistence.js";
|
|
37
37
|
import { detectZrokBinary, cleanupStaleZrok, createTunnel, deleteTunnel, scavengeOrphanZrokProcesses, getTunnelUrl } from "./tunnel.js";
|
|
38
|
+
import { startTunnelWatchdog, stopTunnelWatchdog } from "./tunnel-watchdog.js";
|
|
38
39
|
import { registerAuthPlugin, validateWsUpgrade } from "./auth-plugin.js";
|
|
39
40
|
import { findBundledExtension, registerBridgeExtension } from "@blackbelt-technology/pi-dashboard-shared/bridge-register.js";
|
|
40
41
|
import { createNetworkGuard, isLoopback, isBypassedHost } from "./localhost-guard.js";
|
|
@@ -45,6 +46,8 @@ import { registerSessionRoutes } from "./routes/session-routes.js";
|
|
|
45
46
|
import { registerGitRoutes } from "./routes/git-routes.js";
|
|
46
47
|
import { registerFileRoutes } from "./routes/file-routes.js";
|
|
47
48
|
import { registerOpenSpecRoutes } from "./routes/openspec-routes.js";
|
|
49
|
+
import { registerOpenSpecGroupRoutes } from "./routes/openspec-group-routes.js";
|
|
50
|
+
import { createOpenSpecGroupStore, joinGroupIdsToOpenSpecData } from "./openspec-group-store.js";
|
|
48
51
|
import { registerSystemRoutes } from "./routes/system-routes.js";
|
|
49
52
|
import { registerDoctorRoutes } from "./routes/doctor-routes.js";
|
|
50
53
|
import { registerProviderAuthRoutes } from "./routes/provider-auth-routes.js";
|
|
@@ -58,6 +61,7 @@ import { registerToolRoutes } from "./routes/tool-routes.js";
|
|
|
58
61
|
import { registerJjRoutes } from "./routes/jj-routes.js";
|
|
59
62
|
import { registerBootstrapRoutes } from "./routes/bootstrap-routes.js";
|
|
60
63
|
import { createBootstrapState, type BootstrapStateStore } from "./bootstrap-state.js";
|
|
64
|
+
import { detectLegacyPiInstalls } from "./legacy-pi-cleanup.js";
|
|
61
65
|
import { createBootstrapQueue } from "./bootstrap-queue.js";
|
|
62
66
|
import { bootstrapInstall } from "@blackbelt-technology/pi-dashboard-shared/bootstrap-install.js";
|
|
63
67
|
import { getDefaultRegistry } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js";
|
|
@@ -68,6 +72,11 @@ import { createEditorPidRegistry } from "./editor-pid-registry.js";
|
|
|
68
72
|
import { registerEditorRoutes } from "./routes/editor-routes.js";
|
|
69
73
|
import { registerKnownServersRoutes } from "./routes/known-servers-routes.js";
|
|
70
74
|
import { registerPluginConfigRoutes } from "./routes/plugin-config-routes.js";
|
|
75
|
+
import { createModelProxyAuthGate } from "./model-proxy/auth-gate.js";
|
|
76
|
+
import { registerModelProxyRoutes } from "./routes/model-proxy-routes.js";
|
|
77
|
+
import { registerModelProxyApiKeyRoutes } from "./routes/model-proxy-api-key-routes.js";
|
|
78
|
+
import { registerModelProxyRefreshRoutes } from "./routes/model-proxy-refresh-routes.js";
|
|
79
|
+
import { getModelRegistry, getStreamSimpleFn } from "./model-proxy/registry-singleton.js";
|
|
71
80
|
import { writeConfigPartial } from "./config-api.js";
|
|
72
81
|
import { loadServerEntries, discoverPlugins, getPluginStatusStore } from "@blackbelt-technology/dashboard-plugin-runtime/server";
|
|
73
82
|
import { createServerPluginContext } from "@blackbelt-technology/dashboard-plugin-runtime/server";
|
|
@@ -84,6 +93,12 @@ export interface ServerConfig {
|
|
|
84
93
|
shutdownIdleSeconds: number;
|
|
85
94
|
tunnel: boolean;
|
|
86
95
|
tunnelReservedToken?: string;
|
|
96
|
+
tunnelWatchdog?: {
|
|
97
|
+
enabled: boolean;
|
|
98
|
+
intervalMs: number;
|
|
99
|
+
failureThreshold: number;
|
|
100
|
+
probeTimeoutMs: number;
|
|
101
|
+
};
|
|
87
102
|
authConfig?: AuthConfig;
|
|
88
103
|
/** Override WS ping interval for pi-gateway (ms). Default 60000. Set 0 to disable. */
|
|
89
104
|
pingInterval?: number;
|
|
@@ -479,14 +494,32 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
479
494
|
knownSessionIds.add(s.id);
|
|
480
495
|
}
|
|
481
496
|
|
|
497
|
+
// Create the OpenSpec change-grouping store BEFORE the directory-service so
|
|
498
|
+
// the latter can join `groupId` into every `OpenSpecChange` it produces.
|
|
499
|
+
// See change: add-openspec-change-grouping (task 4.2).
|
|
500
|
+
const openspecGroupStore = createOpenSpecGroupStore();
|
|
501
|
+
|
|
482
502
|
const directoryService = createDirectoryService(
|
|
483
503
|
preferencesStore,
|
|
484
504
|
sessionManager,
|
|
485
505
|
config.openspec,
|
|
506
|
+
{
|
|
507
|
+
enrichOpenSpecData: async (cwd, data) => {
|
|
508
|
+
try {
|
|
509
|
+
const file = await openspecGroupStore.read(cwd);
|
|
510
|
+
return joinGroupIdsToOpenSpecData(data, file.assignments);
|
|
511
|
+
} catch {
|
|
512
|
+
// Bad file (e.g., unsupported schemaVersion) — fall back to unjoined.
|
|
513
|
+
return data;
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
},
|
|
486
517
|
);
|
|
487
518
|
|
|
488
519
|
// mDNS peer discovery state
|
|
489
520
|
let mdnsBrowser: DashboardBrowser | null = null;
|
|
521
|
+
// Optional second-port Fastify instance for model proxy (/v1/*)
|
|
522
|
+
let secondFastify: Awaited<ReturnType<typeof import("fastify").default>> | null = null;
|
|
490
523
|
const peerServers = new Map<string, DiscoveredServer>();
|
|
491
524
|
|
|
492
525
|
const piGateway = createPiGateway(sessionManager, {
|
|
@@ -661,6 +694,14 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
661
694
|
// routes can gate spawn operations on bootstrap status.
|
|
662
695
|
// See change: unified-bootstrap-install.
|
|
663
696
|
const bootstrapState = createBootstrapState();
|
|
697
|
+
// Scan for legacy `@mariozechner/pi-coding-agent` installs so the UI can
|
|
698
|
+
// offer a one-click cleanup. See: legacy-pi-cleanup.ts. Best-effort —
|
|
699
|
+
// detection failure is non-fatal (returns []).
|
|
700
|
+
try {
|
|
701
|
+
bootstrapState.set({ legacyPiInstalls: detectLegacyPiInstalls() });
|
|
702
|
+
} catch (err: any) {
|
|
703
|
+
console.warn("[legacy-pi] detection failed:", err?.message ?? err);
|
|
704
|
+
}
|
|
664
705
|
const bootstrapQueue = createBootstrapQueue();
|
|
665
706
|
// Centralized post-install repair: full ToolRegistry rescan +
|
|
666
707
|
// OpenSpec / pi-resources force-refresh on every `installing → ready`
|
|
@@ -721,6 +762,29 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
721
762
|
if (data) browserGateway.broadcastToAll({ type: "openspec_update", cwd, data });
|
|
722
763
|
},
|
|
723
764
|
});
|
|
765
|
+
// OpenSpec change-grouping routes (store created earlier next to
|
|
766
|
+
// directory-service so the join can run during polls).
|
|
767
|
+
// See change: add-openspec-change-grouping.
|
|
768
|
+
openspecGroupStore.subscribe((cwd, payload) => {
|
|
769
|
+
browserGateway.broadcastToAll({
|
|
770
|
+
type: "openspec_groups_update",
|
|
771
|
+
cwd,
|
|
772
|
+
groups: payload.groups,
|
|
773
|
+
assignments: payload.assignments,
|
|
774
|
+
});
|
|
775
|
+
// Refresh OpenSpecData so the joined `groupId` field reflects the new
|
|
776
|
+
// assignments on subscribers that don't consume `openspec_groups_update`
|
|
777
|
+
// directly. Fire-and-forget; failures are logged inside refreshOpenSpec.
|
|
778
|
+
directoryService.refreshOpenSpec(cwd).then((data) => {
|
|
779
|
+
browserGateway.broadcastToAll({ type: "openspec_update", cwd, data });
|
|
780
|
+
}).catch(() => {});
|
|
781
|
+
});
|
|
782
|
+
registerOpenSpecGroupRoutes(fastify, {
|
|
783
|
+
sessionManager,
|
|
784
|
+
preferencesStore,
|
|
785
|
+
networkGuard,
|
|
786
|
+
store: openspecGroupStore,
|
|
787
|
+
});
|
|
724
788
|
registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version: pkgVersion, directoryService, piGateway, bootstrapState });
|
|
725
789
|
// GET /api/doctor — see change: doctor-rich-output (task 4.2). Auth-gated identically to /api/config.
|
|
726
790
|
registerDoctorRoutes(fastify);
|
|
@@ -792,7 +856,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
792
856
|
const prev = bootstrapState.getLastInstallPackages();
|
|
793
857
|
const packages = prev.length > 0
|
|
794
858
|
? prev
|
|
795
|
-
: ["@earendil-works/pi-coding-agent", "@fission-ai/openspec"
|
|
859
|
+
: ["@earendil-works/pi-coding-agent", "@fission-ai/openspec"];
|
|
796
860
|
bootstrapState.set({
|
|
797
861
|
status: "installing",
|
|
798
862
|
progress: { step: "retry", output: `restarting install (${packages.length} pkg${packages.length === 1 ? "" : "s"})…` },
|
|
@@ -953,7 +1017,51 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
953
1017
|
networkGuard,
|
|
954
1018
|
broadcast: (msg) => browserGateway.broadcast(msg),
|
|
955
1019
|
});
|
|
956
|
-
registerProviderRoutes(fastify, { networkGuard, piGateway, browserGateway });
|
|
1020
|
+
registerProviderRoutes(fastify, { networkGuard, piGateway, browserGateway, port: config.port });
|
|
1021
|
+
|
|
1022
|
+
// ── Model Proxy ───────────────────────────────────────────────────
|
|
1023
|
+
{
|
|
1024
|
+
const fullCfg = loadConfig();
|
|
1025
|
+
if (fullCfg.modelProxy.enabled) {
|
|
1026
|
+
// Register proxy auth gate (runs BEFORE JWT hook for /v1/* routes)
|
|
1027
|
+
const proxyAuthGate = createModelProxyAuthGate({
|
|
1028
|
+
getConfig: () => loadConfig().modelProxy,
|
|
1029
|
+
persistKeyUsage: (apiKeys) => {
|
|
1030
|
+
writeConfigPartial({ modelProxy: { apiKeys } });
|
|
1031
|
+
},
|
|
1032
|
+
});
|
|
1033
|
+
fastify.addHook("onRequest", proxyAuthGate);
|
|
1034
|
+
|
|
1035
|
+
// Register /v1/* routes
|
|
1036
|
+
registerModelProxyRoutes(fastify, {
|
|
1037
|
+
getConfig: () => loadConfig().modelProxy,
|
|
1038
|
+
getRegistry: async () => {
|
|
1039
|
+
try {
|
|
1040
|
+
return await getModelRegistry();
|
|
1041
|
+
} catch {
|
|
1042
|
+
return null;
|
|
1043
|
+
}
|
|
1044
|
+
},
|
|
1045
|
+
streamSimple: (opts: any) => {
|
|
1046
|
+
const fn = getStreamSimpleFn();
|
|
1047
|
+
if (!fn) throw new Error("streamSimple not available");
|
|
1048
|
+
return fn(opts.model, { messages: opts.messages, system: opts.system, tools: opts.tools }, opts);
|
|
1049
|
+
},
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
// Register API key management routes (JWT-gated)
|
|
1053
|
+
registerModelProxyApiKeyRoutes(fastify, {
|
|
1054
|
+
networkGuard,
|
|
1055
|
+
getModelProxyConfig: () => loadConfig().modelProxy,
|
|
1056
|
+
writeModelProxyApiKeys: async (apiKeys) => {
|
|
1057
|
+
writeConfigPartial({ modelProxy: { apiKeys } });
|
|
1058
|
+
},
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
// Register refresh route (JWT-gated)
|
|
1062
|
+
registerModelProxyRefreshRoutes(fastify);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
957
1065
|
|
|
958
1066
|
// Serve static files / SPA fallback.
|
|
959
1067
|
//
|
|
@@ -1086,6 +1194,19 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
1086
1194
|
// Clean up orphan headless processes from a previous server instance
|
|
1087
1195
|
browserGateway.headlessPidRegistry.cleanupOrphans();
|
|
1088
1196
|
|
|
1197
|
+
// Wire the singleton KeeperManager into the headless-pid registry so
|
|
1198
|
+
// `writeRpc` can forward `dispatch_extension_command` lines to the
|
|
1199
|
+
// session's keeper UDS, and so `cleanupKeeperOrphans` can reattach
|
|
1200
|
+
// surviving keepers after a server restart. Same instance the spawn
|
|
1201
|
+
// path uses. See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
1202
|
+
try {
|
|
1203
|
+
const { getKeeperManager } = await import("./process-manager.js");
|
|
1204
|
+
browserGateway.headlessPidRegistry.setKeeperWriter(getKeeperManager());
|
|
1205
|
+
await browserGateway.headlessPidRegistry.cleanupKeeperOrphans();
|
|
1206
|
+
} catch (err) {
|
|
1207
|
+
console.warn("[dashboard] keeper-manager wire-up failed (RPC dispatch disabled):", err);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1089
1210
|
// Clean up orphan code-server processes from a previous server instance.
|
|
1090
1211
|
// Runs before fastify.listen, so no editor start request can race with the sweep.
|
|
1091
1212
|
await editorPidRegistry.cleanupOrphans();
|
|
@@ -1184,6 +1305,40 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
1184
1305
|
console.log(`Dashboard server running at http://localhost:${config.port}`);
|
|
1185
1306
|
console.log(`Pi gateway listening on port ${config.piPort}`);
|
|
1186
1307
|
|
|
1308
|
+
// ── Optional second port for model proxy (/v1/*) ──────────────
|
|
1309
|
+
{
|
|
1310
|
+
const proxyCfg = loadConfig().modelProxy;
|
|
1311
|
+
if (proxyCfg.enabled && proxyCfg.secondPort) {
|
|
1312
|
+
try {
|
|
1313
|
+
const F = (await import("fastify")).default;
|
|
1314
|
+
const sf = F({ logger: false });
|
|
1315
|
+
const proxyAuthGate = createModelProxyAuthGate({
|
|
1316
|
+
getConfig: () => loadConfig().modelProxy,
|
|
1317
|
+
persistKeyUsage: (apiKeys) => {
|
|
1318
|
+
writeConfigPartial({ modelProxy: { apiKeys } });
|
|
1319
|
+
},
|
|
1320
|
+
});
|
|
1321
|
+
sf.addHook("onRequest", proxyAuthGate);
|
|
1322
|
+
registerModelProxyRoutes(sf, {
|
|
1323
|
+
getConfig: () => loadConfig().modelProxy,
|
|
1324
|
+
getRegistry: async () => {
|
|
1325
|
+
try { return await getModelRegistry(); } catch { return null; }
|
|
1326
|
+
},
|
|
1327
|
+
streamSimple: (opts: any) => {
|
|
1328
|
+
const fn = getStreamSimpleFn();
|
|
1329
|
+
if (!fn) throw new Error("streamSimple not available");
|
|
1330
|
+
return fn(opts.model, { messages: opts.messages, system: opts.system, tools: opts.tools }, opts);
|
|
1331
|
+
},
|
|
1332
|
+
});
|
|
1333
|
+
await sf.listen({ port: proxyCfg.secondPort, host: "127.0.0.1" });
|
|
1334
|
+
secondFastify = sf as any;
|
|
1335
|
+
console.log(`Model proxy second port listening at http://127.0.0.1:${proxyCfg.secondPort}`);
|
|
1336
|
+
} catch (err) {
|
|
1337
|
+
console.warn(`Model proxy second port bind failed (continuing without):`, err);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1187
1342
|
// Advertise via mDNS
|
|
1188
1343
|
try {
|
|
1189
1344
|
advertiseDashboard(config.port, config.piPort);
|
|
@@ -1225,6 +1380,21 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
1225
1380
|
const tunnelUrl = await createTunnel(config.port, config.tunnelReservedToken);
|
|
1226
1381
|
if (tunnelUrl) {
|
|
1227
1382
|
console.log(`🌐 Tunnel: ${tunnelUrl}`);
|
|
1383
|
+
// Start the watchdog so a stale zrok edge connection is detected
|
|
1384
|
+
// and recycled automatically (preserves reserved token / URL).
|
|
1385
|
+
const wd = config.tunnelWatchdog;
|
|
1386
|
+
if (wd?.enabled !== false) {
|
|
1387
|
+
startTunnelWatchdog(
|
|
1388
|
+
{
|
|
1389
|
+
getUrl: getTunnelUrl,
|
|
1390
|
+
recycle: async () => {
|
|
1391
|
+
await deleteTunnel(config.port);
|
|
1392
|
+
return await createTunnel(config.port, config.tunnelReservedToken);
|
|
1393
|
+
},
|
|
1394
|
+
},
|
|
1395
|
+
wd,
|
|
1396
|
+
);
|
|
1397
|
+
}
|
|
1228
1398
|
}
|
|
1229
1399
|
}
|
|
1230
1400
|
}
|
|
@@ -1277,6 +1447,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
1277
1447
|
unsubscribeQueueComplete();
|
|
1278
1448
|
bootstrapState.dispose();
|
|
1279
1449
|
bootstrapQueue.clear("server shutting down");
|
|
1450
|
+
stopTunnelWatchdog();
|
|
1280
1451
|
await deleteTunnel(config.port);
|
|
1281
1452
|
piGateway.stop();
|
|
1282
1453
|
for (const client of browserGateway.wss.clients) {
|
|
@@ -1292,6 +1463,11 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
1292
1463
|
editorManager.stopAll();
|
|
1293
1464
|
// Close any pending OAuth callback servers
|
|
1294
1465
|
try { const { closeAllCallbackServers } = await import("./oauth-callback-server.js"); await closeAllCallbackServers(); } catch {}
|
|
1466
|
+
// Close second port before main server
|
|
1467
|
+
if (secondFastify) {
|
|
1468
|
+
try { await secondFastify.close(); } catch { /* ignore */ }
|
|
1469
|
+
secondFastify = null;
|
|
1470
|
+
}
|
|
1295
1471
|
await fastify.close();
|
|
1296
1472
|
},
|
|
1297
1473
|
};
|
|
@@ -17,6 +17,7 @@ import type { BootstrapStateStore } from "./bootstrap-state.js";
|
|
|
17
17
|
import type { BootstrapQueue } from "./bootstrap-queue.js";
|
|
18
18
|
import { attachRenameTarget, detachShouldClearName } from "./proposal-attach-naming.js";
|
|
19
19
|
import { FORK_DEGRADED_TO_NEW_MESSAGE, FORK_DEGRADED_TO_NEW_CODE } from "./browser-handlers/session-action-handler.js";
|
|
20
|
+
import { keeperOptsFromSpawnResult } from "./headless-pid-registry.js";
|
|
20
21
|
|
|
21
22
|
export interface SessionApiDeps {
|
|
22
23
|
sessionManager: SessionManager;
|
|
@@ -239,7 +240,13 @@ export function registerSessionApi(fastify: FastifyInstance, deps: SessionApiDep
|
|
|
239
240
|
const config = loadConfig();
|
|
240
241
|
const spawnResult = await spawnPiSession(cwd, { strategy: config.spawnStrategy });
|
|
241
242
|
if (spawnResult.process && spawnResult.pid) {
|
|
242
|
-
browserGateway.headlessPidRegistry.register(
|
|
243
|
+
browserGateway.headlessPidRegistry.register(
|
|
244
|
+
spawnResult.pid,
|
|
245
|
+
cwd,
|
|
246
|
+
spawnResult.process,
|
|
247
|
+
spawnResult.spawnToken,
|
|
248
|
+
keeperOptsFromSpawnResult(spawnResult),
|
|
249
|
+
);
|
|
243
250
|
}
|
|
244
251
|
if (spawnResult.dashboardSpawned && spawnResult.success) {
|
|
245
252
|
pendingDashboardSpawns?.set(cwd, (pendingDashboardSpawns?.get(cwd) ?? 0) + 1);
|
|
@@ -309,6 +316,7 @@ export function registerSessionApi(fastify: FastifyInstance, deps: SessionApiDep
|
|
|
309
316
|
session.cwd,
|
|
310
317
|
degradeResult.process,
|
|
311
318
|
degradeResult.spawnToken,
|
|
319
|
+
keeperOptsFromSpawnResult(degradeResult),
|
|
312
320
|
);
|
|
313
321
|
}
|
|
314
322
|
if (degradeResult.dashboardSpawned && degradeResult.success) {
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tunnel watchdog: periodically probes the public tunnel URL through the
|
|
3
|
+
* zrok edge and recycles the tunnel when consecutive failures (5xx, network
|
|
4
|
+
* errors, timeouts) exceed a threshold.
|
|
5
|
+
*
|
|
6
|
+
* The zrok `share` subprocess can stay running for days while its connection
|
|
7
|
+
* to the zrok edge silently goes stale, returning HTTP 502 from the public
|
|
8
|
+
* URL even though the local upstream is healthy. The fix is a `deleteTunnel`
|
|
9
|
+
* + `createTunnel` cycle (preserves the reserved token, so the URL stays the
|
|
10
|
+
* same).
|
|
11
|
+
*
|
|
12
|
+
* Probe semantics: we treat ONLY 5xx and network/timeout failures as bad.
|
|
13
|
+
* Any 2xx/3xx/4xx response proves zrok edge ↔ local server connectivity is
|
|
14
|
+
* fine and counts as success — even if the local route is auth-gated.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface TunnelWatchdogDeps {
|
|
18
|
+
/** Returns the active public tunnel URL, or null if no tunnel is up. */
|
|
19
|
+
getUrl: () => string | null;
|
|
20
|
+
/** Recycle the tunnel: delete and recreate. Returns the new URL or null. */
|
|
21
|
+
recycle: () => Promise<string | null>;
|
|
22
|
+
/** Optional fetch override for tests. */
|
|
23
|
+
fetchFn?: typeof fetch;
|
|
24
|
+
/** Optional logger; defaults to console. */
|
|
25
|
+
log?: (level: "info" | "warn" | "error", msg: string) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TunnelWatchdogConfig {
|
|
29
|
+
/** Master switch. Default: true. */
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
/** Probe cadence. Default: 60_000. */
|
|
32
|
+
intervalMs: number;
|
|
33
|
+
/** Consecutive failures before recycling. Default: 2. */
|
|
34
|
+
failureThreshold: number;
|
|
35
|
+
/** Per-probe HTTP timeout. Default: 10_000. */
|
|
36
|
+
probeTimeoutMs: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_TUNNEL_WATCHDOG_CONFIG: TunnelWatchdogConfig = {
|
|
40
|
+
enabled: true,
|
|
41
|
+
intervalMs: 60_000,
|
|
42
|
+
failureThreshold: 2,
|
|
43
|
+
probeTimeoutMs: 10_000,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export interface TunnelWatchdogStatus {
|
|
47
|
+
running: boolean;
|
|
48
|
+
intervalMs: number;
|
|
49
|
+
failureThreshold: number;
|
|
50
|
+
probeTimeoutMs: number;
|
|
51
|
+
lastProbeAt: number | null;
|
|
52
|
+
lastSuccessAt: number | null;
|
|
53
|
+
lastFailureAt: number | null;
|
|
54
|
+
lastFailureReason: string | null;
|
|
55
|
+
consecutiveFailures: number;
|
|
56
|
+
lastRecycleAt: number | null;
|
|
57
|
+
recycleCount: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface WatchdogState {
|
|
61
|
+
cfg: TunnelWatchdogConfig;
|
|
62
|
+
deps: TunnelWatchdogDeps;
|
|
63
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
64
|
+
inFlight: boolean;
|
|
65
|
+
recycling: boolean;
|
|
66
|
+
/** Current backoff multiplier applied after a recycle failure (1, 2, 4, …, capped). */
|
|
67
|
+
backoffMultiplier: number;
|
|
68
|
+
status: TunnelWatchdogStatus;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let state: WatchdogState | null = null;
|
|
72
|
+
|
|
73
|
+
const MAX_BACKOFF_MULTIPLIER = 8;
|
|
74
|
+
|
|
75
|
+
function defaultLog(level: "info" | "warn" | "error", msg: string): void {
|
|
76
|
+
const prefix = "[tunnel-watchdog]";
|
|
77
|
+
if (level === "warn") console.warn(prefix, msg);
|
|
78
|
+
else if (level === "error") console.error(prefix, msg);
|
|
79
|
+
else console.log(prefix, msg);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function makeInitialStatus(cfg: TunnelWatchdogConfig): TunnelWatchdogStatus {
|
|
83
|
+
return {
|
|
84
|
+
running: false,
|
|
85
|
+
intervalMs: cfg.intervalMs,
|
|
86
|
+
failureThreshold: cfg.failureThreshold,
|
|
87
|
+
probeTimeoutMs: cfg.probeTimeoutMs,
|
|
88
|
+
lastProbeAt: null,
|
|
89
|
+
lastSuccessAt: null,
|
|
90
|
+
lastFailureAt: null,
|
|
91
|
+
lastFailureReason: null,
|
|
92
|
+
consecutiveFailures: 0,
|
|
93
|
+
lastRecycleAt: null,
|
|
94
|
+
recycleCount: 0,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Probe outcome: ok=true on 2xx/3xx/4xx, false on 5xx/network/timeout. */
|
|
99
|
+
export async function probeTunnel(
|
|
100
|
+
url: string,
|
|
101
|
+
timeoutMs: number,
|
|
102
|
+
fetchFn: typeof fetch = fetch,
|
|
103
|
+
): Promise<{ ok: boolean; status?: number; reason?: string }> {
|
|
104
|
+
const probeUrl = url.replace(/\/+$/, "") + "/api/health";
|
|
105
|
+
const ctrl = new AbortController();
|
|
106
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
107
|
+
try {
|
|
108
|
+
const res = await fetchFn(probeUrl, { method: "GET", signal: ctrl.signal });
|
|
109
|
+
if (res.status >= 500) {
|
|
110
|
+
return { ok: false, status: res.status, reason: `http ${res.status}` };
|
|
111
|
+
}
|
|
112
|
+
return { ok: true, status: res.status };
|
|
113
|
+
} catch (err: any) {
|
|
114
|
+
const reason = err?.name === "AbortError" ? `timeout ${timeoutMs}ms` : (err?.message || "network error");
|
|
115
|
+
return { ok: false, reason };
|
|
116
|
+
} finally {
|
|
117
|
+
clearTimeout(t);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function scheduleNext(): void {
|
|
122
|
+
if (!state) return;
|
|
123
|
+
const delay = state.cfg.intervalMs * state.backoffMultiplier;
|
|
124
|
+
state.timer = setTimeout(() => { void tick(); }, delay);
|
|
125
|
+
// Don't keep the event loop alive for the watchdog alone.
|
|
126
|
+
if (typeof (state.timer as any).unref === "function") (state.timer as any).unref();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function tick(): Promise<void> {
|
|
130
|
+
if (!state) return;
|
|
131
|
+
if (state.inFlight) { scheduleNext(); return; }
|
|
132
|
+
state.inFlight = true;
|
|
133
|
+
try {
|
|
134
|
+
const url = state.deps.getUrl();
|
|
135
|
+
if (!url) {
|
|
136
|
+
// No tunnel up — nothing to probe; keep ticking in case it comes up.
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const fetchFn = state.deps.fetchFn ?? fetch;
|
|
140
|
+
state.status.lastProbeAt = Date.now();
|
|
141
|
+
const result = await probeTunnel(url, state.cfg.probeTimeoutMs, fetchFn);
|
|
142
|
+
if (result.ok) {
|
|
143
|
+
state.status.lastSuccessAt = Date.now();
|
|
144
|
+
state.status.consecutiveFailures = 0;
|
|
145
|
+
state.backoffMultiplier = 1;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
state.status.lastFailureAt = Date.now();
|
|
149
|
+
state.status.lastFailureReason = result.reason ?? "unknown";
|
|
150
|
+
state.status.consecutiveFailures += 1;
|
|
151
|
+
(state.deps.log ?? defaultLog)(
|
|
152
|
+
"warn",
|
|
153
|
+
`probe failed (${state.status.consecutiveFailures}/${state.cfg.failureThreshold}): ${state.status.lastFailureReason}`,
|
|
154
|
+
);
|
|
155
|
+
if (state.status.consecutiveFailures >= state.cfg.failureThreshold && !state.recycling) {
|
|
156
|
+
await runRecycle();
|
|
157
|
+
}
|
|
158
|
+
} finally {
|
|
159
|
+
state.inFlight = false;
|
|
160
|
+
if (state) scheduleNext();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function runRecycle(): Promise<void> {
|
|
165
|
+
if (!state) return;
|
|
166
|
+
state.recycling = true;
|
|
167
|
+
const log = state.deps.log ?? defaultLog;
|
|
168
|
+
log("warn", `recycling tunnel after ${state.status.consecutiveFailures} consecutive failures`);
|
|
169
|
+
try {
|
|
170
|
+
const newUrl = await state.deps.recycle();
|
|
171
|
+
state.status.lastRecycleAt = Date.now();
|
|
172
|
+
state.status.recycleCount += 1;
|
|
173
|
+
state.status.consecutiveFailures = 0;
|
|
174
|
+
if (newUrl) {
|
|
175
|
+
log("info", `tunnel recycled: ${newUrl}`);
|
|
176
|
+
state.backoffMultiplier = 1;
|
|
177
|
+
} else {
|
|
178
|
+
log("error", "tunnel recycle returned no URL — backing off");
|
|
179
|
+
state.backoffMultiplier = Math.min(state.backoffMultiplier * 2 || 2, MAX_BACKOFF_MULTIPLIER);
|
|
180
|
+
}
|
|
181
|
+
} catch (err: any) {
|
|
182
|
+
log("error", `tunnel recycle threw: ${err?.message ?? err}`);
|
|
183
|
+
state.backoffMultiplier = Math.min(state.backoffMultiplier * 2 || 2, MAX_BACKOFF_MULTIPLIER);
|
|
184
|
+
} finally {
|
|
185
|
+
state.recycling = false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function startTunnelWatchdog(
|
|
190
|
+
deps: TunnelWatchdogDeps,
|
|
191
|
+
cfg: Partial<TunnelWatchdogConfig> = {},
|
|
192
|
+
): void {
|
|
193
|
+
if (state) return; // already running
|
|
194
|
+
const merged: TunnelWatchdogConfig = { ...DEFAULT_TUNNEL_WATCHDOG_CONFIG, ...cfg };
|
|
195
|
+
if (!merged.enabled) return;
|
|
196
|
+
state = {
|
|
197
|
+
cfg: merged,
|
|
198
|
+
deps,
|
|
199
|
+
timer: null,
|
|
200
|
+
inFlight: false,
|
|
201
|
+
recycling: false,
|
|
202
|
+
backoffMultiplier: 1,
|
|
203
|
+
status: makeInitialStatus(merged),
|
|
204
|
+
};
|
|
205
|
+
state.status.running = true;
|
|
206
|
+
scheduleNext();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function stopTunnelWatchdog(): void {
|
|
210
|
+
if (!state) return;
|
|
211
|
+
if (state.timer) clearTimeout(state.timer);
|
|
212
|
+
state.status.running = false;
|
|
213
|
+
state = null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function getTunnelWatchdogStatus(): TunnelWatchdogStatus | null {
|
|
217
|
+
if (!state) return null;
|
|
218
|
+
return { ...state.status };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Test-only: force a tick now (returns when the tick completes). */
|
|
222
|
+
export async function _runTickForTest(): Promise<void> {
|
|
223
|
+
await tick();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Test-only: reset module-level state. */
|
|
227
|
+
export function _resetForTest(): void {
|
|
228
|
+
if (state?.timer) clearTimeout(state.timer);
|
|
229
|
+
state = null;
|
|
230
|
+
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
|
|
18
18
|
const zrokResolver = new ToolResolver({ processExecPath: process.execPath });
|
|
19
19
|
import type { TunnelStatus } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
|
|
20
|
+
import { getTunnelWatchdogStatus } from "./tunnel-watchdog.js";
|
|
20
21
|
import { CONFIG_FILE } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
21
22
|
|
|
22
23
|
export type { TunnelStatus };
|
|
@@ -458,7 +459,10 @@ export function getTunnelUrl(): string | null {
|
|
|
458
459
|
export function getTunnelStatus(): TunnelStatus {
|
|
459
460
|
const serverOs = process.platform;
|
|
460
461
|
if (activeTunnelUrl) {
|
|
461
|
-
|
|
462
|
+
const wd = getTunnelWatchdogStatus();
|
|
463
|
+
return wd
|
|
464
|
+
? { status: "active", url: activeTunnelUrl, serverOs, watchdog: wd }
|
|
465
|
+
: { status: "active", url: activeTunnelUrl, serverOs };
|
|
462
466
|
}
|
|
463
467
|
if (detectZrokBinary()) {
|
|
464
468
|
return { status: "inactive", serverOs };
|