pi-mega-compact 0.20.25 → 0.20.26

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.
@@ -254,9 +254,11 @@ export async function launchDashboardServer(stateDir) {
254
254
  }));
255
255
  v6.listen(port, "::1", () => log("ipv6 loopback bound", { port })); // guardrails-allow PREVENT-PI-004: IPv6 loopback (::1) mirror of the dashboard server
256
256
  }
257
- // Write port.pid
257
+ // Write port.pid (VC0F B2: stamp `version` so the launcher can detect
258
+ // a stale runner by comparing the marker against its own version
259
+ // without an HTTP probe — closes the orphan case for pre-B2 servers).
258
260
  try {
259
- writeFileSync(portFile, JSON.stringify({ port, pid: process.pid }));
261
+ writeFileSync(portFile, JSON.stringify({ port, pid: process.pid, version: SERVER_VERSION }));
260
262
  }
261
263
  catch (e) {
262
264
  log("could not write port.pid", { error: String(e) });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * mega-dashboard-bounce.ts — VC0F stale-runner detection + bounce.
3
+ *
4
+ * Delegate of `mega-dashboard-cmds.ts` (kept under the extensions/ soft limit):
5
+ * owns the once-per-process staleness gate and the pure
6
+ * `bounceStaleRunnerIfAny` decision, which is the durable restart-on-upgrade
7
+ * seam — after `pi update --extensions` replaces the on-disk package, the next
8
+ * `session_start` probes the running dashboard, sees the version mismatch, and
9
+ * kills + respawns the stale runner so it serves the current code.
10
+ *
11
+ * The function is dependency-injected so unit tests (VC0F C1–C3) can stub every
12
+ * primitive; the production wiring (live discovery / version / kill) lives in
13
+ * `mega-dashboard-cmds.ts` and is passed in. No network here — the localhost
14
+ * HTTP probes live behind the injected `isServerRunning`/`serverVersion` deps
15
+ * (already audited PREVENT-PI-004 in the parent file).
16
+ */
17
+ /**
18
+ * Once-per-process staleness gate (VC0F A2). After the FIRST successful probe
19
+ * we skip re-probing on later /mega-dashboard invocations or session-start
20
+ * events within the same extension process — the probe is cheap but the
21
+ * kill+respawn it triggers is disruptive. Set regardless of outcome.
22
+ */
23
+ let stalenessCheckedThisProcess = false;
24
+ /** Test-only seam: clear the once-per-process gate between unit tests. */
25
+ export function resetStalenessGateForTests() {
26
+ stalenessCheckedThisProcess = false;
27
+ }
28
+ /**
29
+ * Detect + replace a STALE dashboard runner for the current repo (VC0F A1).
30
+ *
31
+ * A runner is stale when it is an orphan (live but missing its port.pid
32
+ * marker), when its marker's stamped `version` differs from this extension's
33
+ * own version (VC0F B2 — avoids the HTTP probe), or when the version it
34
+ * reports over HTTP differs from our own (fallback for pre-B2 servers whose
35
+ * marker has no `version` field). A stale runner is killed so the next launch
36
+ * serves the current on-disk code.
37
+ *
38
+ * Best-effort and non-fatal (Goal 4): any failure returns `{bounced:false}`
39
+ * and never throws. Runs at most once per extension process (A2).
40
+ */
41
+ export async function bounceStaleRunnerIfAny(deps) {
42
+ if (stalenessCheckedThisProcess)
43
+ return { bounced: false };
44
+ try {
45
+ stalenessCheckedThisProcess = true; // once per process, regardless of outcome
46
+ const info = await deps.isServerRunning();
47
+ if (!info)
48
+ return { bounced: false };
49
+ const orphan = !info.hasPidFile;
50
+ const want = deps.ownVersion();
51
+ const marker = deps.markerVersion();
52
+ let stale;
53
+ let from = null;
54
+ if (orphan) {
55
+ stale = true; // live server with no marker → orphan by definition
56
+ }
57
+ else if (want != null && marker != null) {
58
+ stale = marker !== want; // B2: compare the stamped marker, skip the HTTP probe
59
+ from = marker;
60
+ }
61
+ else {
62
+ const running = await deps.serverVersion(info.port);
63
+ from = running;
64
+ stale = want != null && running != null && running !== want;
65
+ }
66
+ if (stale) {
67
+ deps.notify(orphan
68
+ ? "[mega-compact] replacing orphaned dashboard server…"
69
+ : `[mega-compact] replacing stale dashboard (${from ?? "?"} → ${want ?? "?"})…`);
70
+ deps.killServerOnPort(info.port);
71
+ return { bounced: true };
72
+ }
73
+ return { bounced: false };
74
+ }
75
+ catch {
76
+ // Best-effort, non-fatal — a failed probe/kill never breaks the caller.
77
+ return { bounced: false };
78
+ }
79
+ }
@@ -9,6 +9,11 @@ import { join, dirname, sep } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { existsSync, writeFileSync, readFileSync, unlinkSync, openSync, closeSync } from "node:fs";
11
11
  import { spawn, execSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: spawns the optional, user-triggered localhost dashboard server only
12
+ import { bounceStaleRunnerIfAny, } from "./mega-dashboard-bounce.js";
13
+ // Re-export the VC0F bounce seam for the unit tests, which import the parent
14
+ // module. The implementation + once-per-process gate live in the sibling
15
+ // delegate file to keep this module under the extensions/ soft limit (400).
16
+ export { bounceStaleRunnerIfAny, resetStalenessGateForTests } from "./mega-dashboard-bounce.js";
12
17
  /** Register the dashboard server lifecycle commands. */
13
18
  export function registerDashboardCommands(pi, runtime) {
14
19
  // H3 fix: read currentStateDir at CALL time, not registration time. The
@@ -118,6 +123,27 @@ export function registerDashboardCommands(pi, runtime) {
118
123
  }
119
124
  catch { /* ignore */ }
120
125
  }
126
+ /** Version stamped in the current repo's port.pid marker (VC0F B2), or null
127
+ * when the marker is absent or predates the stamped `version` field. */
128
+ function markerVersion() {
129
+ try {
130
+ const info = JSON.parse(readFileSync(portFile(), "utf-8"));
131
+ return typeof info.version === "string" ? info.version : null;
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }
137
+ /**
138
+ * Build the dependency set `bounceStaleRunnerIfAny` needs for the CURRENT
139
+ * repo's dashboard lifecycle (port.pid / runner / launch-log all resolve via
140
+ * `runtime.currentStateDir`, so a repo switch re-targets the bounce).
141
+ * `notify` is threaded here so the interactive /mega-dashboard path surfaces
142
+ * the replace messages while the silent session-start path is a no-op.
143
+ */
144
+ function staleBounceDeps(notify) {
145
+ return { isServerRunning, serverVersion, markerVersion, ownVersion, killServerOnPort, notify };
146
+ }
121
147
  /**
122
148
  * Resolve the launchable dashboard-server module.
123
149
  *
@@ -157,9 +183,14 @@ export function registerDashboardCommands(pi, runtime) {
157
183
  if (!resolved)
158
184
  return false;
159
185
  dashboardNeedsStrip = resolved.needsStripTypes;
186
+ // VC0F B1: stamp the extension version in the generated script at WRITE time
187
+ // so a future probe can compare it against ownVersion() without a live HTTP
188
+ // round-trip (useful when the server is hung and /api/version times out).
189
+ const stampedVersion = ownVersion() ?? "0.0.0";
160
190
  const script = [
161
191
  `import { appendFileSync } from "node:fs";`,
162
192
  `const __log = ${JSON.stringify(launchLog())};`,
193
+ `const __VERSION = ${JSON.stringify(stampedVersion)}; // mega-compact bundle version stamped at write time (VC0F B1)`,
163
194
  `function __fail(err) {`,
164
195
  ` const msg = "[mega-compact] dashboard failed: " + (err && err.stack ? err.stack : String(err));`,
165
196
  ` try { appendFileSync(__log, msg + "\\n"); } catch { /* ignore */ }`,
@@ -188,31 +219,18 @@ export function registerDashboardCommands(pi, runtime) {
188
219
  description: "Start the local web dashboard and optionally open it in the default browser.",
189
220
  handler: async (_args, ctx) => {
190
221
  runtime.bindRepo(ctx.cwd);
191
- let info = await isServerRunning();
222
+ // VC0F A1: lift the stale-replace decision out of the handler. Interactive
223
+ // path is unchanged — the user is still notified before a stale runner is
224
+ // killed. When a stale server is bounced, we fall straight through to a
225
+ // fresh spawn; otherwise reuse the live current server if there is one.
226
+ const { bounced } = await bounceStaleRunnerIfAny(staleBounceDeps((msg) => ctx.ui.notify(msg)));
227
+ const info = bounced ? null : await isServerRunning();
192
228
  if (info) {
193
- // Replace the server when it's stale: either (a) an orphan — a live
194
- // server with no port.pid (e.g. left running from a detached spawn or a
195
- // previous upgrade) that keeps serving old HTML from memory; or (b) a
196
- // server that reports a different version than this extension (an older
197
- // build). A live server WITH a matching pid file and version is reused.
198
- const orphan = !info.hasPidFile;
199
- const running = await serverVersion(info.port);
200
- const want = ownVersion();
201
- const stale = orphan || (want != null && running != null && running !== want);
202
- if (stale) {
203
- ctx.ui.notify(orphan
204
- ? "[mega-compact] replacing orphaned dashboard server…"
205
- : `[mega-compact] replacing stale dashboard (${running} → ${want})…`);
206
- killServerOnPort(info.port);
207
- info = null;
208
- }
209
- else {
210
- ctx.ui.notify(`[mega-compact] dashboard already running at ${info.url}`);
211
- const open = await ctx.ui.confirm("mega-compact dashboard", `Open ${info.url} in browser?`);
212
- if (open)
213
- openBrowser(info.url);
214
- return;
215
- }
229
+ ctx.ui.notify(`[mega-compact] dashboard already running at ${info.url}`);
230
+ const open = await ctx.ui.confirm("mega-compact dashboard", `Open ${info.url} in browser?`);
231
+ if (open)
232
+ openBrowser(info.url);
233
+ return;
216
234
  }
217
235
  // Start the server
218
236
  ctx.ui.notify("[mega-compact] starting dashboard server…");
@@ -332,4 +350,19 @@ export function registerDashboardCommands(pi, runtime) {
332
350
  }
333
351
  },
334
352
  });
353
+ // VC0F A3 — durable restart-on-upgrade: after `pi update --extensions`
354
+ // replaces the on-disk package, the next pi session probes the running
355
+ // dashboard, sees the version mismatch, and kills + respawns the stale runner
356
+ // automatically. SILENT — no ctx.ui.notify on this session-start path (unlike
357
+ // the explicit /mega-dashboard path). Best-effort and non-fatal: a probe or
358
+ // kill failure here never breaks session startup.
359
+ pi.on("session_start", async (_event, ctx) => {
360
+ runtime.bindRepo(ctx.cwd);
361
+ try {
362
+ await bounceStaleRunnerIfAny(staleBounceDeps(() => { }));
363
+ }
364
+ catch {
365
+ /* best-effort — never break the session */
366
+ }
367
+ });
335
368
  }
@@ -302,9 +302,11 @@ export async function launchDashboardServer(
302
302
  v6.listen(port, "::1", () => log("ipv6 loopback bound", { port })); // guardrails-allow PREVENT-PI-004: IPv6 loopback (::1) mirror of the dashboard server
303
303
  }
304
304
 
305
- // Write port.pid
305
+ // Write port.pid (VC0F B2: stamp `version` so the launcher can detect
306
+ // a stale runner by comparing the marker against its own version
307
+ // without an HTTP probe — closes the orphan case for pre-B2 servers).
306
308
  try {
307
- writeFileSync(portFile, JSON.stringify({ port, pid: process.pid }));
309
+ writeFileSync(portFile, JSON.stringify({ port, pid: process.pid, version: SERVER_VERSION }));
308
310
  } catch (e) {
309
311
  log("could not write port.pid", { error: String(e) });
310
312
  }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * mega-dashboard-bounce.ts — VC0F stale-runner detection + bounce.
3
+ *
4
+ * Delegate of `mega-dashboard-cmds.ts` (kept under the extensions/ soft limit):
5
+ * owns the once-per-process staleness gate and the pure
6
+ * `bounceStaleRunnerIfAny` decision, which is the durable restart-on-upgrade
7
+ * seam — after `pi update --extensions` replaces the on-disk package, the next
8
+ * `session_start` probes the running dashboard, sees the version mismatch, and
9
+ * kills + respawns the stale runner so it serves the current code.
10
+ *
11
+ * The function is dependency-injected so unit tests (VC0F C1–C3) can stub every
12
+ * primitive; the production wiring (live discovery / version / kill) lives in
13
+ * `mega-dashboard-cmds.ts` and is passed in. No network here — the localhost
14
+ * HTTP probes live behind the injected `isServerRunning`/`serverVersion` deps
15
+ * (already audited PREVENT-PI-004 in the parent file).
16
+ */
17
+
18
+ /**
19
+ * Once-per-process staleness gate (VC0F A2). After the FIRST successful probe
20
+ * we skip re-probing on later /mega-dashboard invocations or session-start
21
+ * events within the same extension process — the probe is cheap but the
22
+ * kill+respawn it triggers is disruptive. Set regardless of outcome.
23
+ */
24
+ let stalenessCheckedThisProcess = false;
25
+
26
+ /** Test-only seam: clear the once-per-process gate between unit tests. */
27
+ export function resetStalenessGateForTests(): void {
28
+ stalenessCheckedThisProcess = false;
29
+ }
30
+
31
+ /** Injectables `bounceStaleRunnerIfAny` needs from the current repo's dashboard
32
+ * lifecycle. Injected so the unit tests (VC0F C1–C3) can stub each primitive
33
+ * independently. */
34
+ export interface BounceStaleDeps {
35
+ /** Discover a live dashboard server for the current repo, or null. */
36
+ isServerRunning(): Promise<{ port: number; url: string; hasPidFile: boolean } | null>;
37
+ /** Version the running server reports via HTTP (/api/version), or null. */
38
+ serverVersion(port: number): Promise<string | null>;
39
+ /** Version stamped in the current repo's port.pid marker, or null. */
40
+ markerVersion(): string | null;
41
+ /** Version of THIS extension package (from its own package.json). */
42
+ ownVersion(): string | null;
43
+ /** Kill the server on `port` and clear its marker (best-effort). */
44
+ killServerOnPort(port: number): void;
45
+ /** User notification; a no-op on the silent session-start path (VC0F A3). */
46
+ notify(message: string): void;
47
+ }
48
+
49
+ /**
50
+ * Detect + replace a STALE dashboard runner for the current repo (VC0F A1).
51
+ *
52
+ * A runner is stale when it is an orphan (live but missing its port.pid
53
+ * marker), when its marker's stamped `version` differs from this extension's
54
+ * own version (VC0F B2 — avoids the HTTP probe), or when the version it
55
+ * reports over HTTP differs from our own (fallback for pre-B2 servers whose
56
+ * marker has no `version` field). A stale runner is killed so the next launch
57
+ * serves the current on-disk code.
58
+ *
59
+ * Best-effort and non-fatal (Goal 4): any failure returns `{bounced:false}`
60
+ * and never throws. Runs at most once per extension process (A2).
61
+ */
62
+ export async function bounceStaleRunnerIfAny(deps: BounceStaleDeps): Promise<{ bounced: boolean }> {
63
+ if (stalenessCheckedThisProcess) return { bounced: false };
64
+ try {
65
+ stalenessCheckedThisProcess = true; // once per process, regardless of outcome
66
+ const info = await deps.isServerRunning();
67
+ if (!info) return { bounced: false };
68
+ const orphan = !info.hasPidFile;
69
+ const want = deps.ownVersion();
70
+ const marker = deps.markerVersion();
71
+ let stale: boolean;
72
+ let from: string | null = null;
73
+ if (orphan) {
74
+ stale = true; // live server with no marker → orphan by definition
75
+ } else if (want != null && marker != null) {
76
+ stale = marker !== want; // B2: compare the stamped marker, skip the HTTP probe
77
+ from = marker;
78
+ } else {
79
+ const running = await deps.serverVersion(info.port);
80
+ from = running;
81
+ stale = want != null && running != null && running !== want;
82
+ }
83
+ if (stale) {
84
+ deps.notify(
85
+ orphan
86
+ ? "[mega-compact] replacing orphaned dashboard server…"
87
+ : `[mega-compact] replacing stale dashboard (${from ?? "?"} → ${want ?? "?"})…`,
88
+ );
89
+ deps.killServerOnPort(info.port);
90
+ return { bounced: true };
91
+ }
92
+ return { bounced: false };
93
+ } catch {
94
+ // Best-effort, non-fatal — a failed probe/kill never breaks the caller.
95
+ return { bounced: false };
96
+ }
97
+ }
@@ -12,6 +12,15 @@ import { fileURLToPath } from "node:url";
12
12
  import { existsSync, writeFileSync, readFileSync, unlinkSync, openSync, closeSync } from "node:fs";
13
13
  import { spawn, execSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: spawns the optional, user-triggered localhost dashboard server only
14
14
  import type { MegaRuntime } from "./mega-runtime.js";
15
+ import {
16
+ bounceStaleRunnerIfAny,
17
+ type BounceStaleDeps,
18
+ } from "./mega-dashboard-bounce.js";
19
+
20
+ // Re-export the VC0F bounce seam for the unit tests, which import the parent
21
+ // module. The implementation + once-per-process gate live in the sibling
22
+ // delegate file to keep this module under the extensions/ soft limit (400).
23
+ export { bounceStaleRunnerIfAny, resetStalenessGateForTests, type BounceStaleDeps } from "./mega-dashboard-bounce.js";
15
24
 
16
25
  /** Register the dashboard server lifecycle commands. */
17
26
  export function registerDashboardCommands(pi: ExtensionAPI, runtime: MegaRuntime): void {
@@ -111,6 +120,28 @@ export function registerDashboardCommands(pi: ExtensionAPI, runtime: MegaRuntime
111
120
  try { unlinkSync(portFile()); } catch { /* ignore */ }
112
121
  }
113
122
 
123
+ /** Version stamped in the current repo's port.pid marker (VC0F B2), or null
124
+ * when the marker is absent or predates the stamped `version` field. */
125
+ function markerVersion(): string | null {
126
+ try {
127
+ const info = JSON.parse(readFileSync(portFile(), "utf-8")) as { version?: unknown };
128
+ return typeof info.version === "string" ? info.version : null;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Build the dependency set `bounceStaleRunnerIfAny` needs for the CURRENT
136
+ * repo's dashboard lifecycle (port.pid / runner / launch-log all resolve via
137
+ * `runtime.currentStateDir`, so a repo switch re-targets the bounce).
138
+ * `notify` is threaded here so the interactive /mega-dashboard path surfaces
139
+ * the replace messages while the silent session-start path is a no-op.
140
+ */
141
+ function staleBounceDeps(notify: (msg: string) => void): BounceStaleDeps {
142
+ return { isServerRunning, serverVersion, markerVersion, ownVersion, killServerOnPort, notify };
143
+ }
144
+
114
145
  /**
115
146
  * Resolve the launchable dashboard-server module.
116
147
  *
@@ -148,9 +179,14 @@ export function registerDashboardCommands(pi: ExtensionAPI, runtime: MegaRuntime
148
179
  const resolved = resolveDashboardEntry();
149
180
  if (!resolved) return false;
150
181
  dashboardNeedsStrip = resolved.needsStripTypes;
182
+ // VC0F B1: stamp the extension version in the generated script at WRITE time
183
+ // so a future probe can compare it against ownVersion() without a live HTTP
184
+ // round-trip (useful when the server is hung and /api/version times out).
185
+ const stampedVersion = ownVersion() ?? "0.0.0";
151
186
  const script = [
152
187
  `import { appendFileSync } from "node:fs";`,
153
188
  `const __log = ${JSON.stringify(launchLog())};`,
189
+ `const __VERSION = ${JSON.stringify(stampedVersion)}; // mega-compact bundle version stamped at write time (VC0F B1)`,
154
190
  `function __fail(err) {`,
155
191
  ` const msg = "[mega-compact] dashboard failed: " + (err && err.stack ? err.stack : String(err));`,
156
192
  ` try { appendFileSync(__log, msg + "\\n"); } catch { /* ignore */ }`,
@@ -181,32 +217,18 @@ export function registerDashboardCommands(pi: ExtensionAPI, runtime: MegaRuntime
181
217
  description: "Start the local web dashboard and optionally open it in the default browser.",
182
218
  handler: async (_args: string, ctx: ExtensionContext) => {
183
219
  runtime.bindRepo(ctx.cwd);
184
- let info = await isServerRunning();
220
+ // VC0F A1: lift the stale-replace decision out of the handler. Interactive
221
+ // path is unchanged — the user is still notified before a stale runner is
222
+ // killed. When a stale server is bounced, we fall straight through to a
223
+ // fresh spawn; otherwise reuse the live current server if there is one.
224
+ const { bounced } = await bounceStaleRunnerIfAny(staleBounceDeps((msg) => ctx.ui.notify(msg)));
225
+ const info = bounced ? null : await isServerRunning();
185
226
 
186
227
  if (info) {
187
- // Replace the server when it's stale: either (a) an orphan — a live
188
- // server with no port.pid (e.g. left running from a detached spawn or a
189
- // previous upgrade) that keeps serving old HTML from memory; or (b) a
190
- // server that reports a different version than this extension (an older
191
- // build). A live server WITH a matching pid file and version is reused.
192
- const orphan = !info.hasPidFile;
193
- const running = await serverVersion(info.port);
194
- const want = ownVersion();
195
- const stale = orphan || (want != null && running != null && running !== want);
196
- if (stale) {
197
- ctx.ui.notify(
198
- orphan
199
- ? "[mega-compact] replacing orphaned dashboard server…"
200
- : `[mega-compact] replacing stale dashboard (${running} → ${want})…`,
201
- );
202
- killServerOnPort(info.port);
203
- info = null;
204
- } else {
205
- ctx.ui.notify(`[mega-compact] dashboard already running at ${info.url}`);
206
- const open = await ctx.ui.confirm("mega-compact dashboard", `Open ${info.url} in browser?`);
207
- if (open) openBrowser(info.url);
208
- return;
209
- }
228
+ ctx.ui.notify(`[mega-compact] dashboard already running at ${info.url}`);
229
+ const open = await ctx.ui.confirm("mega-compact dashboard", `Open ${info.url} in browser?`);
230
+ if (open) openBrowser(info.url);
231
+ return;
210
232
  }
211
233
 
212
234
  // Start the server
@@ -310,4 +332,21 @@ export function registerDashboardCommands(pi: ExtensionAPI, runtime: MegaRuntime
310
332
  }
311
333
  },
312
334
  });
335
+
336
+ // VC0F A3 — durable restart-on-upgrade: after `pi update --extensions`
337
+ // replaces the on-disk package, the next pi session probes the running
338
+ // dashboard, sees the version mismatch, and kills + respawns the stale runner
339
+ // automatically. SILENT — no ctx.ui.notify on this session-start path (unlike
340
+ // the explicit /mega-dashboard path). Best-effort and non-fatal: a probe or
341
+ // kill failure here never breaks session startup.
342
+ pi.on("session_start", async (_event, ctx) => {
343
+ runtime.bindRepo(ctx.cwd);
344
+ try {
345
+ await bounceStaleRunnerIfAny(
346
+ staleBounceDeps(() => { /* session-start path stays silent (VC0F A3) */ }),
347
+ );
348
+ } catch {
349
+ /* best-effort — never break the session */
350
+ }
351
+ });
313
352
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.20.25",
3
+ "version": "0.20.26",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-3-Clause",