@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
|
@@ -32,6 +32,12 @@ export interface WatchdogArmOptions {
|
|
|
32
32
|
mechanism: SpawnMechanism;
|
|
33
33
|
logPath?: string;
|
|
34
34
|
ws: WebSocket;
|
|
35
|
+
/**
|
|
36
|
+
* Server-minted spawn correlation token. When provided, the entry is
|
|
37
|
+
* indexed in `byToken` for strong-identity clearing via `clearByToken`.
|
|
38
|
+
* See change: spawn-correlation-token.
|
|
39
|
+
*/
|
|
40
|
+
spawnToken?: string;
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
interface Entry {
|
|
@@ -42,12 +48,14 @@ interface Entry {
|
|
|
42
48
|
logPath?: string;
|
|
43
49
|
ws: WebSocket;
|
|
44
50
|
timeoutMs: number;
|
|
51
|
+
spawnToken?: string;
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
interface RecentlyFiredEntry {
|
|
48
55
|
firedAt: number;
|
|
49
56
|
pid?: number;
|
|
50
57
|
ws: WebSocket;
|
|
58
|
+
spawnToken?: string;
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
const RECENTLY_FIRED_TTL_MS = 60_000;
|
|
@@ -57,6 +65,7 @@ export class SpawnRegisterWatchdog {
|
|
|
57
65
|
readonly timeoutMs: number;
|
|
58
66
|
private readonly byPid = new Map<number, Entry>();
|
|
59
67
|
private readonly byCwd = new Map<string, Entry>();
|
|
68
|
+
private readonly byToken = new Map<string, Entry>();
|
|
60
69
|
private readonly recentlyFired = new Map<string, RecentlyFiredEntry>();
|
|
61
70
|
|
|
62
71
|
constructor(timeoutMs: number) {
|
|
@@ -68,19 +77,21 @@ export class SpawnRegisterWatchdog {
|
|
|
68
77
|
// takes effect on the next spawn without a server restart.
|
|
69
78
|
// See change: spawn-failure-diagnostics (fix W1).
|
|
70
79
|
const effectiveTimeout = clampSpawnRegisterTimeoutMs(opts.timeoutMs ?? this.timeoutMs);
|
|
71
|
-
const { pid, cwd, mechanism, logPath, ws } = opts;
|
|
80
|
+
const { pid, cwd, mechanism, logPath, ws, spawnToken } = opts;
|
|
72
81
|
const entry: Entry = {
|
|
73
82
|
timer: null as unknown as ReturnType<typeof setTimeout>,
|
|
74
83
|
cwd, pid, mechanism, logPath, ws,
|
|
75
84
|
timeoutMs: effectiveTimeout,
|
|
85
|
+
spawnToken,
|
|
76
86
|
};
|
|
77
87
|
entry.timer = setTimeout(() => this._fireEntry(entry), effectiveTimeout);
|
|
78
88
|
// Always index by cwd so a `session_register` clears the watchdog even
|
|
79
89
|
// when the bridge's reported pid differs from the spawner's pid (e.g.
|
|
80
90
|
// Unix headless wraps pi in `sh -c "tail -f /dev/null | pi …"`, so
|
|
81
91
|
// spawnResult.pid is the sh wrapper, not pi). Index by pid additionally
|
|
82
|
-
// for late-recovery lookup.
|
|
83
|
-
//
|
|
92
|
+
// for late-recovery lookup. Index by token (when provided) for
|
|
93
|
+
// strong-identity clearing. See change: spawn-correlation-token.
|
|
94
|
+
// Replace any prior entry for the same cwd/pid/token to avoid leaking timers.
|
|
84
95
|
const priorCwd = this.byCwd.get(cwd);
|
|
85
96
|
if (priorCwd) clearTimeout(priorCwd.timer);
|
|
86
97
|
this.byCwd.set(cwd, entry);
|
|
@@ -89,6 +100,38 @@ export class SpawnRegisterWatchdog {
|
|
|
89
100
|
if (priorPid && priorPid !== priorCwd) clearTimeout(priorPid.timer);
|
|
90
101
|
this.byPid.set(pid, entry);
|
|
91
102
|
}
|
|
103
|
+
if (spawnToken) {
|
|
104
|
+
const priorTok = this.byToken.get(spawnToken);
|
|
105
|
+
if (priorTok && priorTok !== priorCwd && priorTok !== entry) clearTimeout(priorTok.timer);
|
|
106
|
+
this.byToken.set(spawnToken, entry);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Strong-identity clear: cancel watchdog for this exact spawn invocation.
|
|
112
|
+
* Tier 1 of the three-tier match in `event-wiring.ts`. Removes the entry
|
|
113
|
+
* from all three indices. See change: spawn-correlation-token.
|
|
114
|
+
*/
|
|
115
|
+
clearByToken(spawnToken: string): void {
|
|
116
|
+
const entry = this.byToken.get(spawnToken);
|
|
117
|
+
if (entry) {
|
|
118
|
+
clearTimeout(entry.timer);
|
|
119
|
+
this.byToken.delete(spawnToken);
|
|
120
|
+
const cwdEntry = this.byCwd.get(entry.cwd);
|
|
121
|
+
if (cwdEntry === entry) this.byCwd.delete(entry.cwd);
|
|
122
|
+
if (entry.pid !== undefined) {
|
|
123
|
+
const pidEntry = this.byPid.get(entry.pid);
|
|
124
|
+
if (pidEntry === entry) this.byPid.delete(entry.pid);
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// Check for late recovery: scan recentlyFired for matching token.
|
|
129
|
+
for (const [cwd, fired] of this.recentlyFired) {
|
|
130
|
+
if (fired.spawnToken === spawnToken) {
|
|
131
|
+
this._emitRecovery(cwd, fired);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
92
135
|
}
|
|
93
136
|
|
|
94
137
|
clearByPid(pid: number): void {
|
|
@@ -96,9 +139,13 @@ export class SpawnRegisterWatchdog {
|
|
|
96
139
|
if (entry) {
|
|
97
140
|
clearTimeout(entry.timer);
|
|
98
141
|
this.byPid.delete(pid);
|
|
99
|
-
// Also clear cwd
|
|
142
|
+
// Also clear cwd / token entries if they point at the same arm.
|
|
100
143
|
const cwdEntry = this.byCwd.get(entry.cwd);
|
|
101
144
|
if (cwdEntry === entry) this.byCwd.delete(entry.cwd);
|
|
145
|
+
if (entry.spawnToken) {
|
|
146
|
+
const tokEntry = this.byToken.get(entry.spawnToken);
|
|
147
|
+
if (tokEntry === entry) this.byToken.delete(entry.spawnToken);
|
|
148
|
+
}
|
|
102
149
|
return;
|
|
103
150
|
}
|
|
104
151
|
// Check for late recovery.
|
|
@@ -110,11 +157,15 @@ export class SpawnRegisterWatchdog {
|
|
|
110
157
|
if (entry) {
|
|
111
158
|
clearTimeout(entry.timer);
|
|
112
159
|
this.byCwd.delete(cwd);
|
|
113
|
-
// Also clear pid
|
|
160
|
+
// Also clear pid / token entries if they point at the same arm.
|
|
114
161
|
if (entry.pid !== undefined) {
|
|
115
162
|
const pidEntry = this.byPid.get(entry.pid);
|
|
116
163
|
if (pidEntry === entry) this.byPid.delete(entry.pid);
|
|
117
164
|
}
|
|
165
|
+
if (entry.spawnToken) {
|
|
166
|
+
const tokEntry = this.byToken.get(entry.spawnToken);
|
|
167
|
+
if (tokEntry === entry) this.byToken.delete(entry.spawnToken);
|
|
168
|
+
}
|
|
118
169
|
return;
|
|
119
170
|
}
|
|
120
171
|
// Check for late recovery.
|
|
@@ -131,8 +182,12 @@ export class SpawnRegisterWatchdog {
|
|
|
131
182
|
const cwdEntry = this.byCwd.get(cwd);
|
|
132
183
|
if (cwdEntry === entry) this.byCwd.delete(cwd);
|
|
133
184
|
|
|
134
|
-
// Record in recentlyFired for late-recovery detection.
|
|
135
|
-
|
|
185
|
+
// Record in recentlyFired for late-recovery detection (also drop token entry).
|
|
186
|
+
if (entry.spawnToken) {
|
|
187
|
+
const tokEntry = this.byToken.get(entry.spawnToken);
|
|
188
|
+
if (tokEntry === entry) this.byToken.delete(entry.spawnToken);
|
|
189
|
+
}
|
|
190
|
+
this.recentlyFired.set(cwd, { firedAt: Date.now(), pid, ws, spawnToken: entry.spawnToken });
|
|
136
191
|
|
|
137
192
|
// Read stderr tail if logPath available.
|
|
138
193
|
let stderrTail: string | undefined;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawn correlation token: server-minted UUIDv4 per `spawnPiSession` call.
|
|
3
|
+
*
|
|
4
|
+
* Flow: `mintSpawnToken()` → injected into spawned process env as
|
|
5
|
+
* `PI_DASHBOARD_SPAWN_TOKEN` → bridge reads it → bridge echoes back in
|
|
6
|
+
* `session_register.spawnToken` → server links via `linkByToken`.
|
|
7
|
+
*
|
|
8
|
+
* In-memory only; no persistence. TTL aligned to spawn-register-watchdog.
|
|
9
|
+
*
|
|
10
|
+
* See change: spawn-correlation-token.
|
|
11
|
+
*/
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
/** Mint a fresh UUIDv4 spawn correlation token. */
|
|
15
|
+
export function mintSpawnToken(): string {
|
|
16
|
+
return randomUUID();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Env-var name used for the correlation token in spawned pi processes. */
|
|
20
|
+
export const SPAWN_TOKEN_ENV_VAR = "PI_DASHBOARD_SPAWN_TOKEN";
|
|
@@ -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 };
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `ToolResolver.resolveJiti` — ported from the prior
|
|
3
|
+
* `resolve-jiti.test.ts`. Exercises every anchor in the resolution
|
|
4
|
+
* chain (managed-pi upstream/legacy, system-pi, anchor walk-up,
|
|
5
|
+
* argv fallback, all-miss) plus the URL-shape invariants
|
|
6
|
+
* (`file://` URL output, Windows drive-letter wrapping, upstream
|
|
7
|
+
* jiti chosen before legacy fork).
|
|
8
|
+
*
|
|
9
|
+
* Test seams (`_pathExists`, `_realpath`, `_whichPi`, `_argv1`,
|
|
10
|
+
* `_managedDir`, `resolver`) keep the test pure — no fs / process
|
|
11
|
+
* mutation, no managed-dir on disk.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect } from "vitest";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { ToolResolver, MANAGED_PI_PACKAGES, JITI_PACKAGES } from "../platform/binary-lookup.js";
|
|
16
|
+
|
|
17
|
+
const MANAGED_DIR = "/fake/.pi-dashboard";
|
|
18
|
+
|
|
19
|
+
function makeResolver(installed: Record<string, string>) {
|
|
20
|
+
return (spec: string): string => {
|
|
21
|
+
if (spec in installed) return installed[spec]!;
|
|
22
|
+
throw new Error(`Cannot find module '${spec}'`);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("MANAGED_PI_PACKAGES + JITI_PACKAGES contract", () => {
|
|
27
|
+
it("upstream pi pkg first, legacy fork fallback", () => {
|
|
28
|
+
expect(MANAGED_PI_PACKAGES).toEqual([
|
|
29
|
+
"@earendil-works/pi-coding-agent",
|
|
30
|
+
"@mariozechner/pi-coding-agent",
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("upstream jiti first, legacy fork fallback", () => {
|
|
35
|
+
expect(JITI_PACKAGES).toEqual(["jiti", "@mariozechner/jiti"]);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("ToolResolver.resolveJiti — managed pi", () => {
|
|
40
|
+
it("hits upstream managed pi (@earendil-works) when only it is present", () => {
|
|
41
|
+
const upstreamPkgJson = path.join(
|
|
42
|
+
MANAGED_DIR, "node_modules", "@earendil-works", "pi-coding-agent", "package.json",
|
|
43
|
+
);
|
|
44
|
+
const jitiPkgJson = "/managed/upstream/node_modules/jiti/package.json";
|
|
45
|
+
const url = new ToolResolver().resolveJiti({
|
|
46
|
+
_managedDir: MANAGED_DIR,
|
|
47
|
+
_pathExists: (p) => p === upstreamPkgJson || p === path.join(path.dirname(jitiPkgJson), "lib", "jiti-register.mjs"),
|
|
48
|
+
_whichPi: () => null,
|
|
49
|
+
_argv1: undefined,
|
|
50
|
+
resolver: makeResolver({ "jiti/package.json": jitiPkgJson }),
|
|
51
|
+
});
|
|
52
|
+
expect(url).not.toBeNull();
|
|
53
|
+
expect(url!.startsWith("file://")).toBe(true);
|
|
54
|
+
expect(url!).toMatch(/\/jiti\/lib\/jiti-register\.mjs$/);
|
|
55
|
+
expect(url!).not.toContain("@mariozechner");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("falls through to legacy managed pi (@mariozechner) when upstream is absent", () => {
|
|
59
|
+
const legacyPkgJson = path.join(
|
|
60
|
+
MANAGED_DIR, "node_modules", "@mariozechner", "pi-coding-agent", "package.json",
|
|
61
|
+
);
|
|
62
|
+
const jitiPkgJson = "/managed/legacy/node_modules/@mariozechner/jiti/package.json";
|
|
63
|
+
const url = new ToolResolver().resolveJiti({
|
|
64
|
+
_managedDir: MANAGED_DIR,
|
|
65
|
+
_pathExists: (p) =>
|
|
66
|
+
p === legacyPkgJson ||
|
|
67
|
+
p === path.join(path.dirname(jitiPkgJson), "lib", "jiti-register.mjs"),
|
|
68
|
+
_whichPi: () => null,
|
|
69
|
+
_argv1: undefined,
|
|
70
|
+
resolver: makeResolver({
|
|
71
|
+
"@mariozechner/jiti/package.json": jitiPkgJson,
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
expect(url).not.toBeNull();
|
|
75
|
+
expect(url!).toContain("@mariozechner/jiti");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("prefers upstream pi over legacy when BOTH managed pkgs are present", () => {
|
|
79
|
+
const upstream = path.join(MANAGED_DIR, "node_modules", "@earendil-works", "pi-coding-agent", "package.json");
|
|
80
|
+
const legacy = path.join(MANAGED_DIR, "node_modules", "@mariozechner", "pi-coding-agent", "package.json");
|
|
81
|
+
const upstreamJiti = "/managed/upstream/jiti/package.json";
|
|
82
|
+
const legacyJiti = "/managed/legacy/@mariozechner/jiti/package.json";
|
|
83
|
+
const calls: string[] = [];
|
|
84
|
+
const resolver = (spec: string): string => {
|
|
85
|
+
calls.push(spec);
|
|
86
|
+
if (spec === "jiti/package.json") return upstreamJiti;
|
|
87
|
+
if (spec === "@mariozechner/jiti/package.json") return legacyJiti;
|
|
88
|
+
throw new Error(`nope ${spec}`);
|
|
89
|
+
};
|
|
90
|
+
const url = new ToolResolver().resolveJiti({
|
|
91
|
+
_managedDir: MANAGED_DIR,
|
|
92
|
+
_pathExists: (p) =>
|
|
93
|
+
p === upstream || p === legacy ||
|
|
94
|
+
p === path.join(path.dirname(upstreamJiti), "lib", "jiti-register.mjs"),
|
|
95
|
+
_whichPi: () => null,
|
|
96
|
+
_argv1: undefined,
|
|
97
|
+
resolver,
|
|
98
|
+
});
|
|
99
|
+
expect(url!).toMatch(/\/jiti\/lib\/jiti-register\.mjs$/);
|
|
100
|
+
expect(url!).not.toContain("@mariozechner");
|
|
101
|
+
// Upstream pi anchor produced upstream jiti — legacy pi anchor never tried.
|
|
102
|
+
expect(calls).toEqual(["jiti/package.json"]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("ToolResolver.resolveJiti — system pi", () => {
|
|
107
|
+
it("uses which(\"pi\") when managed pi absent", () => {
|
|
108
|
+
const piBin = "/usr/local/bin/pi";
|
|
109
|
+
const piReal = "/usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js";
|
|
110
|
+
const jitiPkgJson = "/usr/local/lib/node_modules/jiti/package.json";
|
|
111
|
+
const url = new ToolResolver().resolveJiti({
|
|
112
|
+
_managedDir: MANAGED_DIR,
|
|
113
|
+
_pathExists: (p) => p === path.join(path.dirname(jitiPkgJson), "lib", "jiti-register.mjs"),
|
|
114
|
+
_whichPi: () => piBin,
|
|
115
|
+
_realpath: (p) => (p === piBin ? piReal : p),
|
|
116
|
+
_argv1: undefined,
|
|
117
|
+
resolver: makeResolver({ "jiti/package.json": jitiPkgJson }),
|
|
118
|
+
});
|
|
119
|
+
expect(url!.startsWith("file://")).toBe(true);
|
|
120
|
+
expect(url!).toMatch(/\/jiti\/lib\/jiti-register\.mjs$/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("realpaths a symlinked pi binary before resolving", () => {
|
|
124
|
+
const piSymlink = "/usr/local/bin/pi";
|
|
125
|
+
const piTarget = "/opt/pi/dist/cli.js";
|
|
126
|
+
const jitiPkgJson = "/opt/pi/node_modules/jiti/package.json";
|
|
127
|
+
let realpathArg: string | null = null;
|
|
128
|
+
const url = new ToolResolver().resolveJiti({
|
|
129
|
+
_managedDir: MANAGED_DIR,
|
|
130
|
+
// Managed-pi miss; only the symlinked register file exists.
|
|
131
|
+
_pathExists: (p) => p === path.join(path.dirname(jitiPkgJson), "lib", "jiti-register.mjs"),
|
|
132
|
+
_whichPi: () => piSymlink,
|
|
133
|
+
_realpath: (p) => { realpathArg = p; return piTarget; },
|
|
134
|
+
_argv1: undefined,
|
|
135
|
+
resolver: makeResolver({ "jiti/package.json": jitiPkgJson }),
|
|
136
|
+
});
|
|
137
|
+
expect(realpathArg).toBe(piSymlink);
|
|
138
|
+
expect(url).not.toBeNull();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("ToolResolver.resolveJiti — anchor walk-up + argv fallback", () => {
|
|
143
|
+
it("uses caller-supplied anchor when prior layers miss", () => {
|
|
144
|
+
const anchor = "/custom/cli/path.js";
|
|
145
|
+
const jitiPkgJson = "/custom/node_modules/jiti/package.json";
|
|
146
|
+
const url = new ToolResolver().resolveJiti({
|
|
147
|
+
anchor,
|
|
148
|
+
_managedDir: MANAGED_DIR,
|
|
149
|
+
_pathExists: (p) => p === anchor || p === path.join(path.dirname(jitiPkgJson), "lib", "jiti-register.mjs"),
|
|
150
|
+
_whichPi: () => null,
|
|
151
|
+
_argv1: undefined,
|
|
152
|
+
resolver: makeResolver({ "jiti/package.json": jitiPkgJson }),
|
|
153
|
+
});
|
|
154
|
+
expect(url).not.toBeNull();
|
|
155
|
+
expect(url!).toMatch(/\/jiti\/lib\/jiti-register\.mjs$/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("returns null when caller-supplied anchor does not exist on disk", () => {
|
|
159
|
+
const url = new ToolResolver().resolveJiti({
|
|
160
|
+
anchor: "/missing/path.js",
|
|
161
|
+
_managedDir: MANAGED_DIR,
|
|
162
|
+
_pathExists: () => false,
|
|
163
|
+
_whichPi: () => null,
|
|
164
|
+
_argv1: undefined,
|
|
165
|
+
resolver: () => "/whatever/jiti/package.json",
|
|
166
|
+
});
|
|
167
|
+
expect(url).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("falls back to process.argv[1] (test seam) when all earlier anchors miss", () => {
|
|
171
|
+
const argv = "/runtime/argv1/cli.js";
|
|
172
|
+
const jitiPkgJson = "/runtime/node_modules/jiti/package.json";
|
|
173
|
+
const url = new ToolResolver().resolveJiti({
|
|
174
|
+
_managedDir: MANAGED_DIR,
|
|
175
|
+
_pathExists: () => true,
|
|
176
|
+
_whichPi: () => null,
|
|
177
|
+
_realpath: (p) => p,
|
|
178
|
+
_argv1: argv,
|
|
179
|
+
resolver: makeResolver({ "jiti/package.json": jitiPkgJson }),
|
|
180
|
+
});
|
|
181
|
+
expect(url).not.toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("returns null when every anchor misses", () => {
|
|
185
|
+
const url = new ToolResolver().resolveJiti({
|
|
186
|
+
_managedDir: MANAGED_DIR,
|
|
187
|
+
_pathExists: () => false,
|
|
188
|
+
_whichPi: () => null,
|
|
189
|
+
_argv1: undefined,
|
|
190
|
+
resolver: () => { throw new Error("nope"); },
|
|
191
|
+
});
|
|
192
|
+
expect(url).toBeNull();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("ToolResolver.resolveJiti — URL contract", () => {
|
|
197
|
+
it("returns a file:// URL parseable by new URL()", () => {
|
|
198
|
+
const url = new ToolResolver().resolveJiti({
|
|
199
|
+
_managedDir: MANAGED_DIR,
|
|
200
|
+
_pathExists: () => true,
|
|
201
|
+
_whichPi: () => null,
|
|
202
|
+
_argv1: "/runtime/argv1/cli.js",
|
|
203
|
+
_realpath: (p) => p,
|
|
204
|
+
resolver: makeResolver({ "jiti/package.json": "/r/node_modules/jiti/package.json" }),
|
|
205
|
+
});
|
|
206
|
+
expect(url!.startsWith("file://")).toBe(true);
|
|
207
|
+
expect(() => new URL(url!)).not.toThrow();
|
|
208
|
+
expect(url!.endsWith("/lib/jiti-register.mjs")).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("URL-wraps Windows drive-letter pkg.json paths (regression for ERR_UNSUPPORTED_ESM_URL_SCHEME)", () => {
|
|
212
|
+
const winPkgJson = "B:\\Dev\\Nodejs\\global\\node_modules\\@mariozechner\\jiti\\package.json";
|
|
213
|
+
const url = new ToolResolver().resolveJiti({
|
|
214
|
+
_managedDir: MANAGED_DIR,
|
|
215
|
+
_pathExists: () => true,
|
|
216
|
+
_whichPi: () => null,
|
|
217
|
+
_argv1: "C:\\runtime\\cli.js",
|
|
218
|
+
_realpath: (p) => p,
|
|
219
|
+
resolver: makeResolver({ "@mariozechner/jiti/package.json": winPkgJson }),
|
|
220
|
+
});
|
|
221
|
+
expect(url).not.toBeNull();
|
|
222
|
+
expect(url!.startsWith("file:///")).toBe(true);
|
|
223
|
+
expect(() => new URL(url!)).not.toThrow();
|
|
224
|
+
expect(new URL(url!).protocol).toBe("file:");
|
|
225
|
+
expect(url!.toLowerCase()).toContain("/b:/");
|
|
226
|
+
expect(url!.endsWith("/lib/jiti-register.mjs")).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
});
|