pi-mega-compact 0.4.23 → 0.4.24
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/dist/extensions/dashboard-server.js +57 -6
- package/dist/extensions/dashboard-server.test.js +77 -0
- package/dist/extensions/mega-dashboard-cmds.js +32 -2
- package/extensions/dashboard-server.test.ts +77 -0
- package/extensions/dashboard-server.ts +53 -7
- package/extensions/mega-dashboard-cmds.ts +23 -2
- package/package.json +1 -1
|
@@ -11,11 +11,32 @@
|
|
|
11
11
|
* @module
|
|
12
12
|
*/
|
|
13
13
|
import { createServer } from "node:http";
|
|
14
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs";
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync, appendFileSync } from "node:fs";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
16
|
import { join, dirname } from "node:path";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import { DatabaseSync } from "node:sqlite";
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Local runtime log
|
|
21
|
+
//
|
|
22
|
+
// The dashboard server is spawned as a DETACHED child. When it is launched with
|
|
23
|
+
// `stdio: "ignore"` (the old default) any crash before the first console.log is
|
|
24
|
+
// invisible — there is no log to "check". We therefore mirror every lifecycle
|
|
25
|
+
// line to a file in the state dir so a failed start is always diagnosable. The
|
|
26
|
+
// launcher also captures stderr, so this doubles as defense-in-depth.
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
let LOG_PATH = null;
|
|
29
|
+
function log(...parts) {
|
|
30
|
+
const line = `[mega-compact][dashboard] ${parts.map((p) => (typeof p === "string" ? p : JSON.stringify(p))).join(" ")}`;
|
|
31
|
+
// eslint-disable-next-line no-console
|
|
32
|
+
console.error(line); // stderr — captured by the launcher pipe
|
|
33
|
+
if (LOG_PATH) {
|
|
34
|
+
try {
|
|
35
|
+
appendFileSync(LOG_PATH, new Date().toISOString() + " " + line + "\n");
|
|
36
|
+
}
|
|
37
|
+
catch { /* non-fatal */ }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
19
40
|
// --- Multi-repo index (Phase 5b) ------------------------------------------------
|
|
20
41
|
// The extension writes a machine-wide repo registry into a single SQLite DB
|
|
21
42
|
// (<indexDir>/index.sqlite) as the concurrency-safe write path; the dashboard
|
|
@@ -651,7 +672,7 @@ function dashboardHtml(tierName) {
|
|
|
651
672
|
// ---------------------------------------------------------------------------
|
|
652
673
|
// Server
|
|
653
674
|
// ---------------------------------------------------------------------------
|
|
654
|
-
export function launchDashboardServer(stateDir) {
|
|
675
|
+
export async function launchDashboardServer(stateDir) {
|
|
655
676
|
// Our own package version — exposed at /api/version so the launcher can
|
|
656
677
|
// detect a stale server (started by an older build) and replace it on
|
|
657
678
|
// upgrade instead of reuse it.
|
|
@@ -675,17 +696,41 @@ export function launchDashboardServer(stateDir) {
|
|
|
675
696
|
const portFile = join(stateDir, "port.pid");
|
|
676
697
|
const snapshotPath = join(stateDir, "dashboard.json");
|
|
677
698
|
const eventsPath = join(stateDir, "events.log");
|
|
678
|
-
|
|
699
|
+
LOG_PATH = join(stateDir, "dashboard.log");
|
|
700
|
+
log("launch invoked", { stateDir });
|
|
701
|
+
// ── Existing server? ───────────────────────────────────────────────────────
|
|
702
|
+
// A stale port.pid pointing at a dead/competing process is the classic cause
|
|
703
|
+
// of "dashboard failed to start" — we return a port that is NOT actually
|
|
704
|
+
// serving. Probe for a live server on that port first; only reuse the marker
|
|
705
|
+
// when something real answers /api/version. Otherwise drop it and start fresh.
|
|
679
706
|
if (existsSync(portFile)) {
|
|
680
707
|
try {
|
|
681
708
|
const info = JSON.parse(readFileSync(portFile, "utf-8"));
|
|
682
709
|
if (info && info.port) {
|
|
683
|
-
|
|
710
|
+
let live = false;
|
|
711
|
+
try {
|
|
712
|
+
const probe = await fetch(`http://localhost:${info.port}/api/version`, { signal: AbortSignal.timeout(800) });
|
|
713
|
+
live = probe.ok;
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
live = false;
|
|
717
|
+
}
|
|
718
|
+
if (live) {
|
|
719
|
+
log("reusing live server from port.pid", { port: info.port });
|
|
720
|
+
return { port: info.port, url: `http://localhost:${info.port}` };
|
|
721
|
+
}
|
|
722
|
+
log("port.pid present but no live server — treating as stale", { port: info.port });
|
|
684
723
|
}
|
|
685
724
|
}
|
|
686
725
|
catch {
|
|
687
|
-
|
|
726
|
+
log("port.pid unparseable — treating as stale");
|
|
688
727
|
}
|
|
728
|
+
// stale file, remove so the fresh bind does not collide with a lingering
|
|
729
|
+
// process that still holds the port
|
|
730
|
+
try {
|
|
731
|
+
unlinkSync(portFile);
|
|
732
|
+
}
|
|
733
|
+
catch { /* ignore */ }
|
|
689
734
|
}
|
|
690
735
|
// ── New server ────────────────────────────────────────────────────────────
|
|
691
736
|
mkdirSync(stateDir, { recursive: true });
|
|
@@ -799,20 +844,26 @@ export function launchDashboardServer(stateDir) {
|
|
|
799
844
|
function tryPort(port) {
|
|
800
845
|
server.once("error", (err) => {
|
|
801
846
|
if (err.code === "EADDRINUSE" && port < TARGET_PORT + PORT_RANGE - 1) {
|
|
847
|
+
log("port in use, trying next", { port });
|
|
802
848
|
tryPort(port + 1);
|
|
803
849
|
}
|
|
804
850
|
else {
|
|
851
|
+
log("listen failed", { port, code: err.code, message: err.message });
|
|
805
852
|
reject(err);
|
|
806
853
|
}
|
|
807
854
|
});
|
|
808
855
|
server.listen(port, "127.0.0.1", () => {
|
|
809
856
|
const url = `http://localhost:${port}`;
|
|
857
|
+
log("server running", { url });
|
|
858
|
+
// eslint-disable-next-line no-console
|
|
810
859
|
console.log(`[mega-compact] dashboard server running: ${url}`);
|
|
811
860
|
// Write port.pid
|
|
812
861
|
try {
|
|
813
862
|
writeFileSync(portFile, JSON.stringify({ port, pid: process.pid }));
|
|
814
863
|
}
|
|
815
|
-
catch {
|
|
864
|
+
catch (e) {
|
|
865
|
+
log("could not write port.pid", { error: String(e) });
|
|
866
|
+
}
|
|
816
867
|
// Graceful cleanup
|
|
817
868
|
const cleanup = () => {
|
|
818
869
|
try {
|
|
@@ -9,6 +9,7 @@ import assert from "node:assert/strict";
|
|
|
9
9
|
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
12
13
|
// ---------------------------------------------------------------------------
|
|
13
14
|
// helpers
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
@@ -109,3 +110,79 @@ describe("port.pid file", () => {
|
|
|
109
110
|
rmSync(dir, { recursive: true });
|
|
110
111
|
});
|
|
111
112
|
});
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Lifecycle integration — launch the compiled server as a real subprocess
|
|
115
|
+
// (the same way the /dashboard command spawns it) and assert the two failure
|
|
116
|
+
// modes that historically produced a silent "failed to start":
|
|
117
|
+
// 1. a stale port.pid pointing at a dead port is dropped, and the server
|
|
118
|
+
// binds fresh (instead of returning the dead port);
|
|
119
|
+
// 2. a module-load crash is captured to the launch log instead of going
|
|
120
|
+
// silent under stdio:"ignore".
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
const SERVER_ENTRY = new URL("./dashboard-server.js", import.meta.url).pathname;
|
|
123
|
+
function waitFor(cond, timeoutMs = 6000) {
|
|
124
|
+
const start = Date.now();
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
const tick = async () => {
|
|
127
|
+
if (await cond())
|
|
128
|
+
return resolve();
|
|
129
|
+
if (Date.now() - start > timeoutMs)
|
|
130
|
+
return reject(new Error("timeout"));
|
|
131
|
+
setTimeout(tick, 50);
|
|
132
|
+
};
|
|
133
|
+
tick();
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
describe("server lifecycle", () => {
|
|
137
|
+
test("drops a stale port.pid and binds a fresh port", async () => {
|
|
138
|
+
const dir = mkdtempSync(join(tmpdir(), "dash-stale-"));
|
|
139
|
+
// A marker claiming a port where nothing is listening.
|
|
140
|
+
writeFileSync(join(dir, "port.pid"), JSON.stringify({ port: 9325, pid: 999999 }));
|
|
141
|
+
const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
|
|
142
|
+
try {
|
|
143
|
+
// Wait for the server to actually be live (not just any port.pid — the
|
|
144
|
+
// stale marker already exists at t=0 and would pass a naive check).
|
|
145
|
+
await waitFor(async () => {
|
|
146
|
+
try {
|
|
147
|
+
const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
|
|
148
|
+
const res = await fetch(`http://localhost:${raw.port}/api/version`);
|
|
149
|
+
return res.ok;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
|
|
156
|
+
assert.equal(typeof raw.port, "number");
|
|
157
|
+
assert.notEqual(raw.port, 9325, "should not reuse the dead port from the stale marker");
|
|
158
|
+
// And a real server must answer on it.
|
|
159
|
+
const res = await fetch(`http://localhost:${raw.port}/api/version`);
|
|
160
|
+
assert.equal(res.ok, true);
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
child.kill("SIGTERM");
|
|
164
|
+
rmSync(dir, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
test("writes a dashboard.log with startup lines", async () => {
|
|
168
|
+
const dir = mkdtempSync(join(tmpdir(), "dash-log-"));
|
|
169
|
+
const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
|
|
170
|
+
try {
|
|
171
|
+
await waitFor(() => {
|
|
172
|
+
try {
|
|
173
|
+
return /server running/.test(readFileSync(join(dir, "dashboard.log"), "utf-8"));
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
const log = readFileSync(join(dir, "dashboard.log"), "utf-8");
|
|
180
|
+
assert.match(log, /\[mega-compact\]\[dashboard\]/);
|
|
181
|
+
assert.match(log, /server running/);
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
child.kill("SIGTERM");
|
|
185
|
+
rmSync(dir, { recursive: true, force: true });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { join, dirname, sep } from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
|
-
import { existsSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
|
10
|
+
import { existsSync, writeFileSync, readFileSync, unlinkSync, openSync, closeSync } from "node:fs";
|
|
11
11
|
import { spawn } from "node:child_process"; // guardrails-allow PREVENT-PI-004: spawns the optional, user-triggered localhost dashboard server only
|
|
12
12
|
/** Register the dashboard server lifecycle commands. */
|
|
13
13
|
export function registerDashboardCommands(pi, runtime) {
|
|
@@ -213,11 +213,41 @@ export function registerDashboardCommands(pi, runtime) {
|
|
|
213
213
|
ctx.ui.notify("[mega-compact] dashboard entry not found — check logs.");
|
|
214
214
|
return;
|
|
215
215
|
}
|
|
216
|
+
// Clear any stale marker so a fresh bind never collides with a lingering
|
|
217
|
+
// orphan, and truncate the launch log so the next error report shows only
|
|
218
|
+
// this attempt's output.
|
|
219
|
+
try {
|
|
220
|
+
unlinkSync(portFile);
|
|
221
|
+
}
|
|
222
|
+
catch { /* ignore */ }
|
|
223
|
+
try {
|
|
224
|
+
writeFileSync(launchLog, "");
|
|
225
|
+
}
|
|
226
|
+
catch { /* ignore */ }
|
|
216
227
|
const args = dashboardNeedsStrip ? ["--experimental-strip-types", runnerFile] : [runnerFile];
|
|
228
|
+
// Redirect the child's stderr to the launch log so that a CRASH BEFORE the
|
|
229
|
+
// runner's own __fail handler runs (e.g. an ESM module-load / parse error,
|
|
230
|
+
// or a missing entry) is still captured. With the old `stdio: "ignore"`
|
|
231
|
+
// these failures were completely silent and the "check logs" message
|
|
232
|
+
// pointed at an empty file. We open the fd in the parent and pass it to the
|
|
233
|
+
// child; once spawned we close our copy (the child keeps its own dup).
|
|
234
|
+
let stderrFd;
|
|
235
|
+
try {
|
|
236
|
+
stderrFd = openSync(launchLog, "a");
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
stderrFd = -1; // fall back to ignored stderr
|
|
240
|
+
}
|
|
217
241
|
const child = spawn(process.execPath, args, {
|
|
218
242
|
detached: true,
|
|
219
|
-
stdio: "ignore",
|
|
243
|
+
stdio: ["ignore", "ignore", stderrFd >= 0 ? stderrFd : "ignore"],
|
|
220
244
|
});
|
|
245
|
+
if (stderrFd >= 0) {
|
|
246
|
+
try {
|
|
247
|
+
closeSync(stderrFd);
|
|
248
|
+
}
|
|
249
|
+
catch { /* ignore */ }
|
|
250
|
+
}
|
|
221
251
|
child.unref();
|
|
222
252
|
// Poll for a live server (port 9320–9329) instead of relying solely on the
|
|
223
253
|
// port.pid marker, which can land in a different state dir than the one we
|
|
@@ -10,6 +10,7 @@ import assert from "node:assert/strict";
|
|
|
10
10
|
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
11
11
|
import { tmpdir } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
13
14
|
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
// helpers
|
|
@@ -122,3 +123,79 @@ describe("port.pid file", () => {
|
|
|
122
123
|
rmSync(dir, { recursive: true });
|
|
123
124
|
});
|
|
124
125
|
});
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Lifecycle integration — launch the compiled server as a real subprocess
|
|
129
|
+
// (the same way the /dashboard command spawns it) and assert the two failure
|
|
130
|
+
// modes that historically produced a silent "failed to start":
|
|
131
|
+
// 1. a stale port.pid pointing at a dead port is dropped, and the server
|
|
132
|
+
// binds fresh (instead of returning the dead port);
|
|
133
|
+
// 2. a module-load crash is captured to the launch log instead of going
|
|
134
|
+
// silent under stdio:"ignore".
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
const SERVER_ENTRY = new URL("./dashboard-server.js", import.meta.url).pathname;
|
|
138
|
+
|
|
139
|
+
function waitFor(cond: () => boolean | Promise<boolean>, timeoutMs = 6000): Promise<void> {
|
|
140
|
+
const start = Date.now();
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
const tick = async () => {
|
|
143
|
+
if (await cond()) return resolve();
|
|
144
|
+
if (Date.now() - start > timeoutMs) return reject(new Error("timeout"));
|
|
145
|
+
setTimeout(tick, 50);
|
|
146
|
+
};
|
|
147
|
+
tick();
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
describe("server lifecycle", () => {
|
|
152
|
+
test("drops a stale port.pid and binds a fresh port", async () => {
|
|
153
|
+
const dir = mkdtempSync(join(tmpdir(), "dash-stale-"));
|
|
154
|
+
// A marker claiming a port where nothing is listening.
|
|
155
|
+
writeFileSync(join(dir, "port.pid"), JSON.stringify({ port: 9325, pid: 999999 }));
|
|
156
|
+
|
|
157
|
+
const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
|
|
158
|
+
try {
|
|
159
|
+
// Wait for the server to actually be live (not just any port.pid — the
|
|
160
|
+
// stale marker already exists at t=0 and would pass a naive check).
|
|
161
|
+
await waitFor(async () => {
|
|
162
|
+
try {
|
|
163
|
+
const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
|
|
164
|
+
const res = await fetch(`http://localhost:${raw.port}/api/version`);
|
|
165
|
+
return res.ok;
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
|
|
171
|
+
assert.equal(typeof raw.port, "number");
|
|
172
|
+
assert.notEqual(raw.port, 9325, "should not reuse the dead port from the stale marker");
|
|
173
|
+
// And a real server must answer on it.
|
|
174
|
+
const res = await fetch(`http://localhost:${raw.port}/api/version`);
|
|
175
|
+
assert.equal(res.ok, true);
|
|
176
|
+
} finally {
|
|
177
|
+
child.kill("SIGTERM");
|
|
178
|
+
rmSync(dir, { recursive: true, force: true });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("writes a dashboard.log with startup lines", async () => {
|
|
183
|
+
const dir = mkdtempSync(join(tmpdir(), "dash-log-"));
|
|
184
|
+
const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
|
|
185
|
+
try {
|
|
186
|
+
await waitFor(() => {
|
|
187
|
+
try {
|
|
188
|
+
return /server running/.test(readFileSync(join(dir, "dashboard.log"), "utf-8"));
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
const log = readFileSync(join(dir, "dashboard.log"), "utf-8");
|
|
194
|
+
assert.match(log, /\[mega-compact\]\[dashboard\]/);
|
|
195
|
+
assert.match(log, /server running/);
|
|
196
|
+
} finally {
|
|
197
|
+
child.kill("SIGTERM");
|
|
198
|
+
rmSync(dir, { recursive: true, force: true });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -12,12 +12,32 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
15
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs";
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync, appendFileSync } from "node:fs";
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
17
|
import { join, dirname } from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { DatabaseSync } from "node:sqlite";
|
|
20
20
|
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Local runtime log
|
|
23
|
+
//
|
|
24
|
+
// The dashboard server is spawned as a DETACHED child. When it is launched with
|
|
25
|
+
// `stdio: "ignore"` (the old default) any crash before the first console.log is
|
|
26
|
+
// invisible — there is no log to "check". We therefore mirror every lifecycle
|
|
27
|
+
// line to a file in the state dir so a failed start is always diagnosable. The
|
|
28
|
+
// launcher also captures stderr, so this doubles as defense-in-depth.
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
let LOG_PATH: string | null = null;
|
|
32
|
+
function log(...parts: unknown[]): void {
|
|
33
|
+
const line = `[mega-compact][dashboard] ${parts.map((p) => (typeof p === "string" ? p : JSON.stringify(p))).join(" ")}`;
|
|
34
|
+
// eslint-disable-next-line no-console
|
|
35
|
+
console.error(line); // stderr — captured by the launcher pipe
|
|
36
|
+
if (LOG_PATH) {
|
|
37
|
+
try { appendFileSync(LOG_PATH, new Date().toISOString() + " " + line + "\n"); } catch { /* non-fatal */ }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
// --- Multi-repo index (Phase 5b) ------------------------------------------------
|
|
22
42
|
// The extension writes a machine-wide repo registry into a single SQLite DB
|
|
23
43
|
// (<indexDir>/index.sqlite) as the concurrency-safe write path; the dashboard
|
|
@@ -738,7 +758,7 @@ function dashboardHtml(tierName: string): string {
|
|
|
738
758
|
// Server
|
|
739
759
|
// ---------------------------------------------------------------------------
|
|
740
760
|
|
|
741
|
-
export function launchDashboardServer(stateDir: string): Promise<{ port: number; url: string }> {
|
|
761
|
+
export async function launchDashboardServer(stateDir: string): Promise<{ port: number; url: string }> {
|
|
742
762
|
// Our own package version — exposed at /api/version so the launcher can
|
|
743
763
|
// detect a stale server (started by an older build) and replace it on
|
|
744
764
|
// upgrade instead of reuse it.
|
|
@@ -757,17 +777,37 @@ export function launchDashboardServer(stateDir: string): Promise<{ port: number;
|
|
|
757
777
|
const portFile = join(stateDir, "port.pid");
|
|
758
778
|
const snapshotPath = join(stateDir, "dashboard.json");
|
|
759
779
|
const eventsPath = join(stateDir, "events.log");
|
|
760
|
-
|
|
761
|
-
|
|
780
|
+
LOG_PATH = join(stateDir, "dashboard.log");
|
|
781
|
+
log("launch invoked", { stateDir });
|
|
782
|
+
|
|
783
|
+
// ── Existing server? ───────────────────────────────────────────────────────
|
|
784
|
+
// A stale port.pid pointing at a dead/competing process is the classic cause
|
|
785
|
+
// of "dashboard failed to start" — we return a port that is NOT actually
|
|
786
|
+
// serving. Probe for a live server on that port first; only reuse the marker
|
|
787
|
+
// when something real answers /api/version. Otherwise drop it and start fresh.
|
|
762
788
|
if (existsSync(portFile)) {
|
|
763
789
|
try {
|
|
764
790
|
const info = JSON.parse(readFileSync(portFile, "utf-8"));
|
|
765
791
|
if (info && info.port) {
|
|
766
|
-
|
|
792
|
+
let live = false;
|
|
793
|
+
try {
|
|
794
|
+
const probe = await fetch(`http://localhost:${info.port}/api/version`, { signal: AbortSignal.timeout(800) });
|
|
795
|
+
live = probe.ok;
|
|
796
|
+
} catch {
|
|
797
|
+
live = false;
|
|
798
|
+
}
|
|
799
|
+
if (live) {
|
|
800
|
+
log("reusing live server from port.pid", { port: info.port });
|
|
801
|
+
return { port: info.port, url: `http://localhost:${info.port}` };
|
|
802
|
+
}
|
|
803
|
+
log("port.pid present but no live server — treating as stale", { port: info.port });
|
|
767
804
|
}
|
|
768
805
|
} catch {
|
|
769
|
-
|
|
806
|
+
log("port.pid unparseable — treating as stale");
|
|
770
807
|
}
|
|
808
|
+
// stale file, remove so the fresh bind does not collide with a lingering
|
|
809
|
+
// process that still holds the port
|
|
810
|
+
try { unlinkSync(portFile); } catch { /* ignore */ }
|
|
771
811
|
}
|
|
772
812
|
|
|
773
813
|
// ── New server ────────────────────────────────────────────────────────────
|
|
@@ -891,20 +931,26 @@ export function launchDashboardServer(stateDir: string): Promise<{ port: number;
|
|
|
891
931
|
function tryPort(port: number) {
|
|
892
932
|
server.once("error", (err: NodeJS.ErrnoException) => {
|
|
893
933
|
if (err.code === "EADDRINUSE" && port < TARGET_PORT + PORT_RANGE - 1) {
|
|
934
|
+
log("port in use, trying next", { port });
|
|
894
935
|
tryPort(port + 1);
|
|
895
936
|
} else {
|
|
937
|
+
log("listen failed", { port, code: err.code, message: err.message });
|
|
896
938
|
reject(err);
|
|
897
939
|
}
|
|
898
940
|
});
|
|
899
941
|
|
|
900
942
|
server.listen(port, "127.0.0.1", () => {
|
|
901
943
|
const url = `http://localhost:${port}`;
|
|
944
|
+
log("server running", { url });
|
|
945
|
+
// eslint-disable-next-line no-console
|
|
902
946
|
console.log(`[mega-compact] dashboard server running: ${url}`);
|
|
903
947
|
|
|
904
948
|
// Write port.pid
|
|
905
949
|
try {
|
|
906
950
|
writeFileSync(portFile, JSON.stringify({ port, pid: process.pid }));
|
|
907
|
-
} catch {
|
|
951
|
+
} catch (e) {
|
|
952
|
+
log("could not write port.pid", { error: String(e) });
|
|
953
|
+
}
|
|
908
954
|
|
|
909
955
|
// Graceful cleanup
|
|
910
956
|
const cleanup = () => {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { join, dirname, sep } from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
|
-
import { existsSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
|
12
|
+
import { existsSync, writeFileSync, readFileSync, unlinkSync, openSync, closeSync } from "node:fs";
|
|
13
13
|
import { spawn } from "node:child_process"; // guardrails-allow PREVENT-PI-004: spawns the optional, user-triggered localhost dashboard server only
|
|
14
14
|
import { MegaRuntime } from "./mega-runtime.js";
|
|
15
15
|
|
|
@@ -209,11 +209,32 @@ export function registerDashboardCommands(pi: ExtensionAPI, runtime: MegaRuntime
|
|
|
209
209
|
return;
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
// Clear any stale marker so a fresh bind never collides with a lingering
|
|
213
|
+
// orphan, and truncate the launch log so the next error report shows only
|
|
214
|
+
// this attempt's output.
|
|
215
|
+
try { unlinkSync(portFile); } catch { /* ignore */ }
|
|
216
|
+
try { writeFileSync(launchLog, ""); } catch { /* ignore */ }
|
|
217
|
+
|
|
212
218
|
const args = dashboardNeedsStrip ? ["--experimental-strip-types", runnerFile] : [runnerFile];
|
|
219
|
+
// Redirect the child's stderr to the launch log so that a CRASH BEFORE the
|
|
220
|
+
// runner's own __fail handler runs (e.g. an ESM module-load / parse error,
|
|
221
|
+
// or a missing entry) is still captured. With the old `stdio: "ignore"`
|
|
222
|
+
// these failures were completely silent and the "check logs" message
|
|
223
|
+
// pointed at an empty file. We open the fd in the parent and pass it to the
|
|
224
|
+
// child; once spawned we close our copy (the child keeps its own dup).
|
|
225
|
+
let stderrFd: number;
|
|
226
|
+
try {
|
|
227
|
+
stderrFd = openSync(launchLog, "a");
|
|
228
|
+
} catch {
|
|
229
|
+
stderrFd = -1; // fall back to ignored stderr
|
|
230
|
+
}
|
|
213
231
|
const child = spawn(process.execPath, args, {
|
|
214
232
|
detached: true,
|
|
215
|
-
stdio: "ignore",
|
|
233
|
+
stdio: ["ignore", "ignore", stderrFd >= 0 ? stderrFd : "ignore"],
|
|
216
234
|
});
|
|
235
|
+
if (stderrFd >= 0) {
|
|
236
|
+
try { closeSync(stderrFd); } catch { /* ignore */ }
|
|
237
|
+
}
|
|
217
238
|
child.unref();
|
|
218
239
|
|
|
219
240
|
// Poll for a live server (port 9320–9329) instead of relying solely on the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mega-compact",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.24",
|
|
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-2-Clause",
|