@blackbelt-technology/pi-agent-dashboard 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +26 -5
- package/README.md +49 -7
- package/docs/architecture.md +129 -1
- package/package.json +15 -15
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
- package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +78 -8
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
- package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
- package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
- package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
- package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
- package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
- package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
- package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
- package/packages/extension/src/ask-user-tool.ts +1 -1
- package/packages/extension/src/bridge-context.ts +68 -4
- package/packages/extension/src/bridge.ts +79 -11
- package/packages/extension/src/command-handler.ts +95 -15
- package/packages/extension/src/flow-event-wiring.ts +1 -1
- package/packages/extension/src/multiselect-list.ts +1 -1
- package/packages/extension/src/pi-env.d.ts +16 -9
- package/packages/extension/src/prompt-expander.ts +74 -63
- package/packages/extension/src/provider-register.ts +16 -9
- package/packages/extension/src/retry-tracker.ts +123 -0
- package/packages/extension/src/server-launcher.ts +31 -70
- package/packages/extension/src/session-sync.ts +10 -1
- package/packages/extension/src/slash-dispatch.ts +123 -0
- package/packages/extension/src/usage-limit-orderer.ts +76 -0
- package/packages/server/bin/pi-dashboard.mjs +84 -0
- package/packages/server/package.json +8 -7
- package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
- package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
- package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
- package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +16 -4
- package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service.test.ts +2 -2
- package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
- package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +68 -1
- package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
- package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +316 -0
- package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
- package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
- package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
- package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
- package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
- package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
- package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
- package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
- package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
- package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
- package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
- package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
- package/packages/server/src/__tests__/package-routes.test.ts +1 -1
- package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
- package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
- package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
- package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +62 -3
- package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
- package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
- package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
- package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +13 -23
- package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +3 -3
- package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +84 -0
- package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
- package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
- package/packages/server/src/auth-plugin.ts +3 -0
- package/packages/server/src/bootstrap-state.ts +10 -0
- package/packages/server/src/browser-gateway.ts +27 -10
- package/packages/server/src/browser-handlers/handler-context.ts +9 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +128 -19
- package/packages/server/src/changelog-fs.ts +167 -0
- package/packages/server/src/changelog-parser.ts +321 -0
- package/packages/server/src/changelog-remote.ts +134 -0
- package/packages/server/src/cli.ts +62 -82
- package/packages/server/src/config-api.ts +14 -2
- package/packages/server/src/directory-service.ts +106 -4
- package/packages/server/src/event-wiring.ts +90 -6
- package/packages/server/src/headless-pid-registry.ts +344 -37
- package/packages/server/src/legacy-pi-cleanup.ts +151 -0
- package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
- package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
- package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
- package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
- package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
- package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
- package/packages/server/src/model-proxy/api-key-store.ts +87 -0
- package/packages/server/src/model-proxy/auth-gate.ts +116 -0
- package/packages/server/src/model-proxy/concurrency.ts +76 -0
- package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
- package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
- package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
- package/packages/server/src/model-proxy/convert/index.ts +8 -0
- package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
- package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
- package/packages/server/src/model-proxy/convert/types.ts +70 -0
- package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
- package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
- package/packages/server/src/model-proxy/internal-registry.ts +157 -0
- package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
- package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
- package/packages/server/src/model-proxy/request-log.ts +53 -0
- package/packages/server/src/model-proxy/streamer.ts +59 -0
- package/packages/server/src/openspec-group-store.ts +490 -0
- package/packages/server/src/pending-client-correlations.ts +73 -0
- package/packages/server/src/pending-fork-registry.ts +24 -12
- package/packages/server/src/pi-core-checker.ts +77 -17
- package/packages/server/src/pi-core-updater.ts +16 -6
- package/packages/server/src/pi-dev-version-check.ts +145 -0
- package/packages/server/src/pi-gateway.ts +4 -0
- package/packages/server/src/pi-version-skew.ts +12 -4
- package/packages/server/src/process-manager.ts +182 -11
- package/packages/server/src/provider-auth-storage.ts +29 -47
- package/packages/server/src/provider-catalogue-cache.ts +24 -18
- package/packages/server/src/restart-helper.ts +17 -16
- package/packages/server/src/routes/bootstrap-routes.ts +37 -0
- package/packages/server/src/routes/jj-routes.ts +3 -0
- package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
- package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
- package/packages/server/src/routes/model-proxy-routes.ts +330 -0
- package/packages/server/src/routes/openspec-group-routes.ts +231 -0
- package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
- package/packages/server/src/routes/pi-core-routes.ts +1 -1
- package/packages/server/src/routes/provider-auth-routes.ts +8 -1
- package/packages/server/src/routes/provider-routes.ts +28 -5
- package/packages/server/src/routes/system-routes.ts +44 -2
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
- package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
- package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
- package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
- package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
- package/packages/server/src/server.ts +254 -60
- package/packages/server/src/session-api.ts +63 -4
- package/packages/server/src/session-discovery.ts +1 -1
- package/packages/server/src/session-file-reader.ts +1 -1
- package/packages/server/src/spawn-register-watchdog.ts +62 -7
- package/packages/server/src/spawn-token.ts +20 -0
- package/packages/server/src/tunnel-watchdog.ts +230 -0
- package/packages/server/src/tunnel.ts +5 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +24 -17
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +5 -3
- package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
- package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
- package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
- package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
- package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
- package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
- package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
- package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
- package/packages/shared/src/bootstrap-install.ts +1 -1
- package/packages/shared/src/browser-protocol.ts +70 -0
- package/packages/shared/src/changelog-types.ts +111 -0
- package/packages/shared/src/config.ts +172 -2
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
- package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
- package/packages/shared/src/platform/binary-lookup.ts +204 -0
- package/packages/shared/src/platform/node-spawn.ts +71 -26
- package/packages/shared/src/protocol.ts +27 -1
- package/packages/shared/src/recommended-extensions.ts +18 -0
- package/packages/shared/src/rest-api.ts +219 -1
- package/packages/shared/src/server-launcher.ts +277 -0
- package/packages/shared/src/skill-block-parser.ts +1 -1
- package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
- package/packages/shared/src/tool-registry/definitions.ts +15 -3
- package/packages/shared/src/types.ts +62 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -53
- package/packages/shared/src/resolve-jiti.ts +0 -102
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KeeperManager — server-side helper for spawning, writing to, killing,
|
|
3
|
+
* and discovering RPC keeper sidecars.
|
|
4
|
+
*
|
|
5
|
+
* One keeper process per headless session. The keeper itself is
|
|
6
|
+
* `keeper.cjs` (CJS-pure). KeeperManager bridges between the dashboard
|
|
7
|
+
* server's TypeScript world and the spawned CJS subprocess.
|
|
8
|
+
*
|
|
9
|
+
* Tasks: 4.1, 4.2, 4.3, 4.4, 4.5.
|
|
10
|
+
* See: openspec/changes/add-rpc-stdin-dispatch-with-keeper-sidecar
|
|
11
|
+
* - specs/rpc-keeper-sidecar/spec.md (lifecycle + discovery contract)
|
|
12
|
+
* - design.md Decisions 4 + 8
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
existsSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
openSync,
|
|
18
|
+
readdirSync,
|
|
19
|
+
readFileSync,
|
|
20
|
+
unlinkSync,
|
|
21
|
+
} from "node:fs";
|
|
22
|
+
import net from "node:net";
|
|
23
|
+
import os from "node:os";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import type { ChildProcess } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
|
|
27
|
+
import {
|
|
28
|
+
spawnDetached as defaultSpawnDetached,
|
|
29
|
+
type SpawnDetachedOptions,
|
|
30
|
+
type SpawnDetachedResult,
|
|
31
|
+
} from "@blackbelt-technology/pi-dashboard-shared/platform/detached-spawn.js";
|
|
32
|
+
import {
|
|
33
|
+
isProcessAlive,
|
|
34
|
+
killPidWithGroup,
|
|
35
|
+
} from "@blackbelt-technology/pi-dashboard-shared/platform/process.js";
|
|
36
|
+
|
|
37
|
+
// ── Path conventions ─────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
function defaultSessionsDir(): string {
|
|
40
|
+
return path.join(os.homedir(), ".pi", "dashboard", "sessions");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function defaultKeeperPath(): string {
|
|
44
|
+
// `keeper.cjs` sits alongside this module. Works under jiti (source dir)
|
|
45
|
+
// and any preserve-structure build (dist/rpc-keeper/keeper.cjs).
|
|
46
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
47
|
+
return path.join(here, "keeper.cjs");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function sockPathFor(
|
|
51
|
+
sessionsDir: string,
|
|
52
|
+
sessionId: string,
|
|
53
|
+
platform: NodeJS.Platform = process.platform,
|
|
54
|
+
): string {
|
|
55
|
+
return platform === "win32"
|
|
56
|
+
? `\\\\.\\pipe\\pi-rpc-${sessionId}`
|
|
57
|
+
: path.join(sessionsDir, `${sessionId}.rpc.sock`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function pidPathFor(
|
|
61
|
+
sessionsDir: string,
|
|
62
|
+
sessionId: string,
|
|
63
|
+
platform: NodeJS.Platform = process.platform,
|
|
64
|
+
): string {
|
|
65
|
+
return platform === "win32"
|
|
66
|
+
? path.join(sessionsDir, `pi-rpc-${sessionId}.pid`)
|
|
67
|
+
: `${sockPathFor(sessionsDir, sessionId, platform)}.pid`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function keeperLogPath(sessionsDir: string, sessionId: string): string {
|
|
71
|
+
return path.join(sessionsDir, `keeper-${sessionId}.log`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Public types ─────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
export interface KeeperSpawnResult {
|
|
77
|
+
success: boolean;
|
|
78
|
+
/** Keeper process PID. NOT pi's PID (pi PID is linked later via token correlation). */
|
|
79
|
+
pid?: number;
|
|
80
|
+
/** Absolute path to the UDS / named pipe the keeper listens on. */
|
|
81
|
+
sockPath?: string;
|
|
82
|
+
/** Underlying child process handle. */
|
|
83
|
+
process?: ChildProcess;
|
|
84
|
+
/** Error message when `success: false`. */
|
|
85
|
+
error?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface KeeperEntry {
|
|
89
|
+
sessionId: string;
|
|
90
|
+
keeperPid: number;
|
|
91
|
+
sockPath: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface KeeperManager {
|
|
95
|
+
/** Spawn a keeper for `sessionId`. Resolves once the keeper has a PID. */
|
|
96
|
+
spawnKeeperFor(
|
|
97
|
+
sessionId: string,
|
|
98
|
+
cwd: string,
|
|
99
|
+
env: NodeJS.ProcessEnv,
|
|
100
|
+
piArgs?: string[],
|
|
101
|
+
): Promise<KeeperSpawnResult>;
|
|
102
|
+
/** Connect to keeper UDS, write `line + \n`, close. Never throws. */
|
|
103
|
+
writeRpc(sessionId: string, line: string): Promise<boolean>;
|
|
104
|
+
/**
|
|
105
|
+
* Connect to an arbitrary UDS / named-pipe path, write `line + \n`, close.
|
|
106
|
+
* Used by `headless-pid-registry.writeRpc` so the registry can delegate
|
|
107
|
+
* line-write semantics (3-attempt retry with backoffs, never throws)
|
|
108
|
+
* without re-implementing the connect logic. Returns false on all-attempts-failed.
|
|
109
|
+
* See change: add-rpc-stdin-dispatch-with-keeper-sidecar (Phase 6).
|
|
110
|
+
*/
|
|
111
|
+
writeRpcToSockPath(sockPath: string, line: string): Promise<boolean>;
|
|
112
|
+
/** SIGTERM the keeper PID for `sessionId` (via process-group on Unix). */
|
|
113
|
+
killKeeper(sessionId: string): boolean;
|
|
114
|
+
/** Scan sessions dir; return live keeper+pi pairs; unlink stale entries. */
|
|
115
|
+
discoverExistingKeepers(): Promise<KeeperEntry[]>;
|
|
116
|
+
/** For tests / introspection. */
|
|
117
|
+
readonly sessionsDir: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Dependency-injection options ─────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
export interface KeeperManagerOptions {
|
|
123
|
+
/** Override the sessions dir (default `~/.pi/dashboard/sessions`). */
|
|
124
|
+
sessionsDir?: string;
|
|
125
|
+
/** Override the absolute path to `keeper.cjs`. */
|
|
126
|
+
keeperPath?: string;
|
|
127
|
+
/** Override the node binary used to invoke the keeper (default `process.execPath`). */
|
|
128
|
+
nodeBinary?: string;
|
|
129
|
+
/**
|
|
130
|
+
* Callback used by `discoverExistingKeepers` to verify the corresponding
|
|
131
|
+
* pi process is alive (the keeper-pid liveness is checked internally).
|
|
132
|
+
* Default: always returns true — caller MUST inject a real probe (typically
|
|
133
|
+
* wired to `headlessPidRegistry`) when using `discoverExistingKeepers` for
|
|
134
|
+
* orphan reconciliation.
|
|
135
|
+
*/
|
|
136
|
+
isPiAliveForSession?: (sessionId: string, keeperPid: number) => boolean;
|
|
137
|
+
/**
|
|
138
|
+
* Override the OS for path-convention computation. Default: `process.platform`.
|
|
139
|
+
* Only affects socket / pid-sidecar path shape; spawn dispatch is handled
|
|
140
|
+
* inside `spawnDetached` already.
|
|
141
|
+
*/
|
|
142
|
+
platform?: NodeJS.Platform;
|
|
143
|
+
/** Test seam — override `spawnDetached`. */
|
|
144
|
+
spawnDetached?: (opts: SpawnDetachedOptions) => Promise<SpawnDetachedResult>;
|
|
145
|
+
/** Test seam — override `net.createConnection`. */
|
|
146
|
+
createConnection?: typeof net.createConnection;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Implementation ───────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/** Per-attempt connect timeout for `writeRpc`. */
|
|
152
|
+
const WRITE_RPC_ATTEMPT_TIMEOUT_MS = 350;
|
|
153
|
+
/** Backoffs before retry attempts 2 and 3. Task 4.3. */
|
|
154
|
+
const WRITE_RPC_RETRY_DELAYS_MS = [50, 150];
|
|
155
|
+
/** Total attempts including the initial one. */
|
|
156
|
+
const WRITE_RPC_MAX_ATTEMPTS = 3;
|
|
157
|
+
|
|
158
|
+
export function createKeeperManager(opts: KeeperManagerOptions = {}): KeeperManager {
|
|
159
|
+
const sessionsDir = opts.sessionsDir ?? defaultSessionsDir();
|
|
160
|
+
const keeperPath = opts.keeperPath ?? defaultKeeperPath();
|
|
161
|
+
const nodeBinary = opts.nodeBinary ?? process.execPath;
|
|
162
|
+
const platform = opts.platform ?? process.platform;
|
|
163
|
+
const isPiAlive = opts.isPiAliveForSession ?? (() => true);
|
|
164
|
+
const spawnDetached = opts.spawnDetached ?? defaultSpawnDetached;
|
|
165
|
+
const createConnection = opts.createConnection ?? net.createConnection;
|
|
166
|
+
|
|
167
|
+
// sessionId → keeperPid for fast killKeeper without rescanning the dir.
|
|
168
|
+
// (Discovery rebuilds this from the filesystem on startup.)
|
|
169
|
+
const tracked = new Map<string, number>();
|
|
170
|
+
|
|
171
|
+
function ensureSessionsDir(): void {
|
|
172
|
+
try { mkdirSync(sessionsDir, { recursive: true }); } catch { /* mkdir failure surfaced by keeper itself */ }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function spawnKeeperFor(
|
|
176
|
+
sessionId: string,
|
|
177
|
+
cwd: string,
|
|
178
|
+
env: NodeJS.ProcessEnv,
|
|
179
|
+
piArgs?: string[],
|
|
180
|
+
): Promise<KeeperSpawnResult> {
|
|
181
|
+
if (!sessionId || typeof sessionId !== "string") {
|
|
182
|
+
return { success: false, error: "sessionId required" };
|
|
183
|
+
}
|
|
184
|
+
if (!existsSync(keeperPath)) {
|
|
185
|
+
return { success: false, error: `keeper.cjs not found at ${keeperPath}` };
|
|
186
|
+
}
|
|
187
|
+
ensureSessionsDir();
|
|
188
|
+
|
|
189
|
+
// Per-spawn log for the parent-side stdio capture. The keeper itself
|
|
190
|
+
// writes its primary log to `keeper-<sid>.log`; this captures any
|
|
191
|
+
// bootstrap stderr (e.g. keeper failed to open its own log).
|
|
192
|
+
const launchLogPath = path.join(sessionsDir, `keeper-launch-${sessionId}.log`);
|
|
193
|
+
let logFd: number | undefined;
|
|
194
|
+
try { logFd = openSync(launchLogPath, "a"); } catch { logFd = undefined; }
|
|
195
|
+
|
|
196
|
+
// Forward pi argv to the keeper via env var (avoids shell-quoting
|
|
197
|
+
// pitfalls of stuffing them into argv). Keeper reads PI_KEEPER_PI_ARGS
|
|
198
|
+
// and strips it from pi's env before spawning pi. Defaults to bare RPC
|
|
199
|
+
// when piArgs is omitted, preserving simple test/direct-invocation use.
|
|
200
|
+
const keeperEnv: NodeJS.ProcessEnv = piArgs && piArgs.length > 0
|
|
201
|
+
? { ...env, PI_KEEPER_PI_ARGS: JSON.stringify(piArgs) }
|
|
202
|
+
: env;
|
|
203
|
+
|
|
204
|
+
// Delegate to the shared cross-platform primitive so libuv-correct
|
|
205
|
+
// defaults (detached: true on POSIX, Job-Object exclusion + windowsHide
|
|
206
|
+
// on win32) are uniform.
|
|
207
|
+
const r = await spawnDetached({
|
|
208
|
+
cmd: nodeBinary,
|
|
209
|
+
args: [keeperPath, sessionId],
|
|
210
|
+
cwd,
|
|
211
|
+
env: keeperEnv,
|
|
212
|
+
logFd,
|
|
213
|
+
stdinMode: "ignore",
|
|
214
|
+
detach: true,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
if (!r.ok || typeof r.pid !== "number") {
|
|
218
|
+
return { success: false, error: r.error ?? "spawn returned no pid" };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Detach: let the keeper continue if this Node process exits.
|
|
222
|
+
try { r.process?.unref(); } catch { /* ignore */ }
|
|
223
|
+
|
|
224
|
+
tracked.set(sessionId, r.pid);
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
success: true,
|
|
228
|
+
pid: r.pid,
|
|
229
|
+
sockPath: sockPathFor(sessionsDir, sessionId, platform),
|
|
230
|
+
process: r.process,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function tryConnectAndWrite(sockPath: string, line: string, timeoutMs: number): Promise<boolean> {
|
|
235
|
+
return new Promise((resolve) => {
|
|
236
|
+
let settled = false;
|
|
237
|
+
const settle = (ok: boolean): void => {
|
|
238
|
+
if (settled) return;
|
|
239
|
+
settled = true;
|
|
240
|
+
resolve(ok);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
let sock: net.Socket;
|
|
244
|
+
try {
|
|
245
|
+
sock = createConnection(sockPath);
|
|
246
|
+
} catch {
|
|
247
|
+
settle(false);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const timer = setTimeout(() => {
|
|
252
|
+
try { sock.destroy(); } catch { /* ignore */ }
|
|
253
|
+
settle(false);
|
|
254
|
+
}, timeoutMs);
|
|
255
|
+
|
|
256
|
+
sock.once("connect", () => {
|
|
257
|
+
sock.end(line.endsWith("\n") ? line : line + "\n", "utf8", () => {
|
|
258
|
+
clearTimeout(timer);
|
|
259
|
+
settle(true);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
sock.once("error", () => {
|
|
263
|
+
clearTimeout(timer);
|
|
264
|
+
settle(false);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function writeRpcToSockPath(sockPath: string, line: string): Promise<boolean> {
|
|
270
|
+
for (let attempt = 0; attempt < WRITE_RPC_MAX_ATTEMPTS; attempt++) {
|
|
271
|
+
if (attempt > 0) {
|
|
272
|
+
await new Promise((r) => setTimeout(r, WRITE_RPC_RETRY_DELAYS_MS[attempt - 1]));
|
|
273
|
+
}
|
|
274
|
+
const ok = await tryConnectAndWrite(sockPath, line, WRITE_RPC_ATTEMPT_TIMEOUT_MS).catch(() => false);
|
|
275
|
+
if (ok) return true;
|
|
276
|
+
}
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function writeRpc(sessionId: string, line: string): Promise<boolean> {
|
|
281
|
+
const sockPath = sockPathFor(sessionsDir, sessionId, platform);
|
|
282
|
+
return writeRpcToSockPath(sockPath, line);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function killKeeper(sessionId: string): boolean {
|
|
286
|
+
const pid = tracked.get(sessionId);
|
|
287
|
+
if (typeof pid !== "number") return false;
|
|
288
|
+
try {
|
|
289
|
+
killPidWithGroup(pid, "SIGTERM");
|
|
290
|
+
return true;
|
|
291
|
+
} catch {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function readPidSidecar(p: string): number | null {
|
|
297
|
+
try {
|
|
298
|
+
const raw = readFileSync(p, "utf8").trim();
|
|
299
|
+
const n = Number.parseInt(raw, 10);
|
|
300
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
301
|
+
} catch {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function unlinkQuiet(p: string): void {
|
|
307
|
+
try { unlinkSync(p); } catch { /* ignore */ }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function discoverExistingKeepers(): Promise<KeeperEntry[]> {
|
|
311
|
+
if (!existsSync(sessionsDir)) return [];
|
|
312
|
+
let names: string[];
|
|
313
|
+
try { names = readdirSync(sessionsDir); } catch { return []; }
|
|
314
|
+
|
|
315
|
+
const result: KeeperEntry[] = [];
|
|
316
|
+
// The PID sidecar is the source of truth (Windows named pipes have no
|
|
317
|
+
// filesystem entry to scan). On Unix the .pid sidecar lives alongside
|
|
318
|
+
// the .sock; on Windows it's named `pi-rpc-<sid>.pid`.
|
|
319
|
+
const isWin = platform === "win32";
|
|
320
|
+
for (const name of names) {
|
|
321
|
+
let sessionId: string | null = null;
|
|
322
|
+
if (isWin) {
|
|
323
|
+
const m = name.match(/^pi-rpc-(.+)\.pid$/);
|
|
324
|
+
if (m) sessionId = m[1];
|
|
325
|
+
} else {
|
|
326
|
+
const m = name.match(/^(.+)\.rpc\.sock\.pid$/);
|
|
327
|
+
if (m) sessionId = m[1];
|
|
328
|
+
}
|
|
329
|
+
if (!sessionId) continue;
|
|
330
|
+
|
|
331
|
+
const pidFile = path.join(sessionsDir, name);
|
|
332
|
+
const sockPath = sockPathFor(sessionsDir, sessionId, platform);
|
|
333
|
+
const keeperPid = readPidSidecar(pidFile);
|
|
334
|
+
|
|
335
|
+
if (!keeperPid || !isProcessAlive(keeperPid)) {
|
|
336
|
+
// Stale keeper sidecar: clean it up. Best-effort socket unlink too.
|
|
337
|
+
unlinkQuiet(pidFile);
|
|
338
|
+
if (!isWin) unlinkQuiet(sockPath);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (!isPiAlive(sessionId, keeperPid)) {
|
|
343
|
+
// Keeper alive but pi dead → kill keeper, clean up.
|
|
344
|
+
try { killPidWithGroup(keeperPid, "SIGTERM"); } catch { /* ignore */ }
|
|
345
|
+
unlinkQuiet(pidFile);
|
|
346
|
+
if (!isWin) unlinkQuiet(sockPath);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
tracked.set(sessionId, keeperPid);
|
|
351
|
+
result.push({ sessionId, keeperPid, sockPath });
|
|
352
|
+
}
|
|
353
|
+
return result;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
spawnKeeperFor,
|
|
358
|
+
writeRpc,
|
|
359
|
+
writeRpcToSockPath,
|
|
360
|
+
killKeeper,
|
|
361
|
+
discoverExistingKeepers,
|
|
362
|
+
sessionsDir,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* RPC keeper sidecar.
|
|
4
|
+
*
|
|
5
|
+
* Spawned by the dashboard server as `node keeper.cjs <sessionId>`.
|
|
6
|
+
* Owns pi's stdin pipe; forwards JSON-line writes received on a per-session
|
|
7
|
+
* UDS / named pipe to pi's stdin verbatim. Outlives the dashboard server.
|
|
8
|
+
*
|
|
9
|
+
* CommonJS-pure: only Node built-ins. No jiti / tsx / typescript loader.
|
|
10
|
+
* Mirrors the constraint pattern of `preload-fastify.cjs`.
|
|
11
|
+
*
|
|
12
|
+
* See: openspec/changes/add-rpc-stdin-dispatch-with-keeper-sidecar
|
|
13
|
+
* - specs/rpc-keeper-sidecar/spec.md
|
|
14
|
+
* - design.md (Decisions 1, 2, 3, 8, 9)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
"use strict";
|
|
18
|
+
|
|
19
|
+
const child_process = require("child_process");
|
|
20
|
+
const fs = require("fs");
|
|
21
|
+
const net = require("net");
|
|
22
|
+
const os = require("os");
|
|
23
|
+
const path = require("path");
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Args + paths
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
const sessionId = process.argv[2];
|
|
30
|
+
if (!sessionId || typeof sessionId !== "string") {
|
|
31
|
+
// Cannot open the keeper log without a sessionId. Write to stderr (which the
|
|
32
|
+
// KeeperManager wires to the spawn log) and exit non-zero.
|
|
33
|
+
process.stderr.write("[keeper] FATAL: missing sessionId argv[2]\n");
|
|
34
|
+
process.exit(2);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const SESSIONS_DIR = path.join(os.homedir(), ".pi", "dashboard", "sessions");
|
|
38
|
+
try {
|
|
39
|
+
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
40
|
+
} catch (_e) { /* ignore — fs.openSync below will fail with a clearer error */ }
|
|
41
|
+
|
|
42
|
+
// Socket / pipe path conventions per spec (Decision 3 in design.md)
|
|
43
|
+
const isWindows = process.platform === "win32";
|
|
44
|
+
const sockPath = isWindows
|
|
45
|
+
? `\\\\.\\pipe\\pi-rpc-${sessionId}`
|
|
46
|
+
: path.join(SESSIONS_DIR, `${sessionId}.rpc.sock`);
|
|
47
|
+
|
|
48
|
+
// PID sidecar conventions per rpc-keeper-sidecar Requirement
|
|
49
|
+
const pidPath = isWindows
|
|
50
|
+
? path.join(SESSIONS_DIR, `pi-rpc-${sessionId}.pid`)
|
|
51
|
+
: `${sockPath}.pid`;
|
|
52
|
+
|
|
53
|
+
const logPath = path.join(SESSIONS_DIR, `keeper-${sessionId}.log`);
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Logger
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
let logFd;
|
|
60
|
+
try {
|
|
61
|
+
logFd = fs.openSync(logPath, "a");
|
|
62
|
+
} catch (e) {
|
|
63
|
+
process.stderr.write(`[keeper ${sessionId}] FATAL: cannot open log ${logPath}: ${e && e.message}\n`);
|
|
64
|
+
process.exit(2);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function log(line) {
|
|
68
|
+
try {
|
|
69
|
+
fs.writeSync(logFd, `[${new Date().toISOString()}] ${line}\n`);
|
|
70
|
+
} catch (_e) { /* swallow — log failure should not crash the keeper */ }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
log(`keeper starting: sessionId=${sessionId} pid=${process.pid} sockPath=${sockPath}`);
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Shutdown coordination
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
let shuttingDown = false;
|
|
80
|
+
let server; // net.Server
|
|
81
|
+
let piChild; // child_process.ChildProcess
|
|
82
|
+
|
|
83
|
+
function unlinkQuiet(p) {
|
|
84
|
+
try { fs.unlinkSync(p); } catch (_e) { /* ignore */ }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function shutdown(exitCode, reason) {
|
|
88
|
+
if (shuttingDown) return;
|
|
89
|
+
shuttingDown = true;
|
|
90
|
+
log(`shutdown: code=${exitCode} reason=${reason || "n/a"}`);
|
|
91
|
+
|
|
92
|
+
// Close the server first so no new connections come in.
|
|
93
|
+
try { if (server) server.close(); } catch (_e) { /* ignore */ }
|
|
94
|
+
|
|
95
|
+
// Best-effort cleanup. On Windows named pipes the socket file itself is
|
|
96
|
+
// virtual and need not be unlinked; on Unix we unlink the socket file.
|
|
97
|
+
if (!isWindows) unlinkQuiet(sockPath);
|
|
98
|
+
unlinkQuiet(pidPath);
|
|
99
|
+
|
|
100
|
+
// Don't wait on logFd close — process.exit will tear down fds.
|
|
101
|
+
process.exit(exitCode);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
process.on("SIGTERM", () => shutdown(0, "SIGTERM"));
|
|
105
|
+
process.on("SIGINT", () => shutdown(0, "SIGINT"));
|
|
106
|
+
process.on("uncaughtException", (e) => {
|
|
107
|
+
log(`uncaughtException: ${e && e.stack ? e.stack : e}`);
|
|
108
|
+
shutdown(1, "uncaughtException");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Bind socket BEFORE spawning pi (Decision 4 / Failure-modes Requirement)
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
function startServer(retried) {
|
|
116
|
+
return new Promise((resolve) => {
|
|
117
|
+
const s = net.createServer(handleConnection);
|
|
118
|
+
|
|
119
|
+
s.once("error", (err) => {
|
|
120
|
+
// EADDRINUSE (Unix) / EADDRINUSE-like on Windows pipes: stale socket file
|
|
121
|
+
// from a previous keeper that crashed without cleanup. Per spec: unlink
|
|
122
|
+
// and retry exactly once.
|
|
123
|
+
if (!retried && err && (err.code === "EADDRINUSE" || err.code === "EACCES")) {
|
|
124
|
+
log(`bind failed (${err.code}); unlinking stale path and retrying once`);
|
|
125
|
+
if (!isWindows) unlinkQuiet(sockPath);
|
|
126
|
+
// small backoff before retry
|
|
127
|
+
setTimeout(() => {
|
|
128
|
+
startServer(true).then(resolve);
|
|
129
|
+
}, 50);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
log(`FATAL: bind failed (retried=${retried}): ${err && err.message}`);
|
|
133
|
+
shutdown(2, "bind-failed");
|
|
134
|
+
resolve(null);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
s.listen(sockPath, () => {
|
|
138
|
+
log(`socket bound: ${sockPath}`);
|
|
139
|
+
// Set restrictive permissions on Unix UDS file (Windows pipes use ACLs).
|
|
140
|
+
if (!isWindows) {
|
|
141
|
+
try { fs.chmodSync(sockPath, 0o600); } catch (_e) { /* best-effort */ }
|
|
142
|
+
}
|
|
143
|
+
resolve(s);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Connection handler — JSON-lines, fire-and-forget, dumb wire
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
function handleConnection(sock) {
|
|
153
|
+
log(`connection accepted`);
|
|
154
|
+
let buf = "";
|
|
155
|
+
|
|
156
|
+
sock.setEncoding("utf8");
|
|
157
|
+
sock.on("data", (chunk) => {
|
|
158
|
+
buf += chunk;
|
|
159
|
+
// Split on \n; keep the trailing partial in buf.
|
|
160
|
+
let nl;
|
|
161
|
+
// eslint-disable-next-line no-cond-assign
|
|
162
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
163
|
+
const line = buf.slice(0, nl);
|
|
164
|
+
buf = buf.slice(nl + 1);
|
|
165
|
+
forwardLine(line);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
sock.on("end", () => {
|
|
169
|
+
// Flush a trailing line without newline as a complete line. Pi's RPC
|
|
170
|
+
// reader expects newline-framed lines, so we must append \n anyway.
|
|
171
|
+
if (buf.length > 0) {
|
|
172
|
+
forwardLine(buf);
|
|
173
|
+
buf = "";
|
|
174
|
+
}
|
|
175
|
+
log(`connection ended`);
|
|
176
|
+
});
|
|
177
|
+
sock.on("error", (err) => {
|
|
178
|
+
log(`connection error: ${err && err.message}`);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function forwardLine(line) {
|
|
183
|
+
// No JSON parsing or content validation — keeper is a dumb wire.
|
|
184
|
+
if (!piChild || !piChild.stdin || piChild.stdin.destroyed) {
|
|
185
|
+
log(`drop line (pi stdin unavailable): ${line.slice(0, 80)}`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
piChild.stdin.write(line + "\n");
|
|
190
|
+
} catch (e) {
|
|
191
|
+
// pi.stdin EPIPE etc. Logged, but the actual EPIPE handler below will
|
|
192
|
+
// trigger shutdown via pi.stdin.on("error", ...) on the next event-loop tick.
|
|
193
|
+
log(`forwardLine error: ${e && e.message}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// Pi spawn + lifecycle
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
const CRASH_WINDOW_MS = 300;
|
|
202
|
+
let piSpawnedAt = 0;
|
|
203
|
+
|
|
204
|
+
function readPiArgs() {
|
|
205
|
+
// PI_KEEPER_PI_ARGS is a JSON-encoded string array of pi argv tokens.
|
|
206
|
+
// Set by KeeperManager to forward the dashboard's per-spawn flags
|
|
207
|
+
// (--session-file, --mode continue, --fork, etc.) so resume / fork
|
|
208
|
+
// round-trip correctly through the keeper. Default falls back to
|
|
209
|
+
// bare RPC mode for direct invocations and tests.
|
|
210
|
+
const raw = process.env.PI_KEEPER_PI_ARGS;
|
|
211
|
+
if (!raw) return ["--mode", "rpc"];
|
|
212
|
+
try {
|
|
213
|
+
const parsed = JSON.parse(raw);
|
|
214
|
+
if (Array.isArray(parsed) && parsed.every((s) => typeof s === "string")) {
|
|
215
|
+
return parsed;
|
|
216
|
+
}
|
|
217
|
+
log(`WARN: PI_KEEPER_PI_ARGS not a string[]; falling back to default`);
|
|
218
|
+
} catch (e) {
|
|
219
|
+
log(`WARN: PI_KEEPER_PI_ARGS parse failed (${e && e.message}); falling back to default`);
|
|
220
|
+
}
|
|
221
|
+
return ["--mode", "rpc"];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function spawnPi() {
|
|
225
|
+
const piArgs = readPiArgs();
|
|
226
|
+
log(`spawning pi ${piArgs.join(" ")}`);
|
|
227
|
+
// env is inherited from process.env (KeeperManager already set up the
|
|
228
|
+
// proper PATH and PI_DASHBOARD_SPAWNED). Defensively set the flag again
|
|
229
|
+
// here in case the keeper is invoked manually. Strip the keeper-internal
|
|
230
|
+
// PI_KEEPER_PI_ARGS so it doesn't leak into pi's env.
|
|
231
|
+
const env = Object.assign({}, process.env, { PI_DASHBOARD_SPAWNED: "1" });
|
|
232
|
+
delete env.PI_KEEPER_PI_ARGS;
|
|
233
|
+
|
|
234
|
+
piSpawnedAt = Date.now();
|
|
235
|
+
const c = child_process.spawn("pi", piArgs, {
|
|
236
|
+
stdio: ["pipe", logFd, logFd],
|
|
237
|
+
env,
|
|
238
|
+
cwd: process.cwd(),
|
|
239
|
+
windowsHide: true,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
c.on("error", (err) => {
|
|
243
|
+
log(`pi spawn error: ${err && err.message}`);
|
|
244
|
+
shutdown(1, "pi-spawn-error");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
c.on("exit", (code, signal) => {
|
|
248
|
+
const elapsed = Date.now() - piSpawnedAt;
|
|
249
|
+
log(`pi exited code=${code} signal=${signal} elapsed=${elapsed}ms`);
|
|
250
|
+
// If pi exited within the crash-detection window, surface a non-zero
|
|
251
|
+
// exit code so the parent (KeeperManager / process-manager) can preserve
|
|
252
|
+
// the existing dashboard PI_CRASHED semantic. Otherwise a graceful pi
|
|
253
|
+
// exit → keeper exit 0.
|
|
254
|
+
if (elapsed < CRASH_WINDOW_MS) {
|
|
255
|
+
shutdown(1, "pi-crashed-early");
|
|
256
|
+
} else {
|
|
257
|
+
shutdown(0, "pi-exit");
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Detect EPIPE / closed-stream errors on pi.stdin: per spec, treat as same
|
|
262
|
+
// as pi.exit (the pipe is gone; pi will follow shortly if not already).
|
|
263
|
+
if (c.stdin) {
|
|
264
|
+
c.stdin.on("error", (err) => {
|
|
265
|
+
log(`pi.stdin error: ${err && err.code}/${err && err.message}`);
|
|
266
|
+
// EPIPE is the canonical case; treat any stdin error as terminal.
|
|
267
|
+
shutdown(0, "pi-stdin-error");
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return c;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// Startup orchestration
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
async function main() {
|
|
279
|
+
// 1. Bind socket FIRST so the server can start retrying immediately.
|
|
280
|
+
server = await startServer(false);
|
|
281
|
+
if (!server) return; // shutdown already triggered
|
|
282
|
+
|
|
283
|
+
// 2. Write PID sidecar.
|
|
284
|
+
try {
|
|
285
|
+
fs.writeFileSync(pidPath, String(process.pid), "utf8");
|
|
286
|
+
} catch (e) {
|
|
287
|
+
log(`FATAL: cannot write PID sidecar ${pidPath}: ${e && e.message}`);
|
|
288
|
+
shutdown(2, "pid-sidecar-write");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 3. Spawn pi.
|
|
293
|
+
piChild = spawnPi();
|
|
294
|
+
|
|
295
|
+
// 4. Crash-detection window: emit the "keeper ready" marker once pi has
|
|
296
|
+
// survived the crash window. The crash-on-early-exit decision itself is
|
|
297
|
+
// made by the c.on("exit") handler comparing elapsed vs CRASH_WINDOW_MS
|
|
298
|
+
// — unifying the two paths so the early-exit code wins regardless of
|
|
299
|
+
// which fires first.
|
|
300
|
+
setTimeout(() => {
|
|
301
|
+
if (shuttingDown) return;
|
|
302
|
+
if (piChild && piChild.exitCode === null && piChild.signalCode === null) {
|
|
303
|
+
log(`keeper ready: ${sessionId}`);
|
|
304
|
+
}
|
|
305
|
+
// If pi already exited, c.on("exit") has already (or will imminently)
|
|
306
|
+
// call shutdown(1) via the elapsed-time check.
|
|
307
|
+
}, CRASH_WINDOW_MS);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
main().catch((e) => {
|
|
311
|
+
log(`FATAL main: ${e && e.stack ? e.stack : e}`);
|
|
312
|
+
shutdown(2, "main-rejected");
|
|
313
|
+
});
|