@xynogen/pix-runtime 0.10.2 → 0.13.0
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/LICENSE +21 -0
- package/README.md +209 -12
- package/package.json +12 -1
- package/src/audio.ts +298 -0
- package/src/binaries/catalog.ts +279 -0
- package/src/binaries/ensure.ts +294 -0
- package/src/binaries/index.ts +43 -0
- package/src/binaries/resolve.ts +199 -0
- package/src/binaries/store.ts +149 -0
- package/src/binaries-tab.ts +364 -0
- package/src/exec.ts +365 -0
- package/src/extension.ts +35 -3
- package/src/hashline.check.mjs +11 -0
- package/src/hashline.ts +71 -0
- package/src/icon-catalog.ts +1 -0
- package/src/migrations.ts +41 -10
- package/src/os.ts +250 -0
- package/src/paths.ts +87 -0
- package/src/persistence.ts +122 -28
- package/src/pix-command.ts +132 -17
- package/src/platform.ts +75 -0
- package/src/runtime.ts +126 -80
- package/src/safe-path.ts +101 -0
- package/src/sections/index.ts +16 -0
- package/src/sections/pretty.ts +37 -2
- package/src/sections/services.ts +71 -0
- package/src/testing.ts +19 -3
- package/src/user-shell.ts +51 -0
- package/src/which.ts +21 -4
package/src/pix-command.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Headless hosts get a notify summary instead of the overlay.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { dirname } from "node:path";
|
|
9
10
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
10
11
|
import {
|
|
11
12
|
Key,
|
|
@@ -15,9 +16,10 @@ import {
|
|
|
15
16
|
visibleWidth,
|
|
16
17
|
wrapTextWithAnsi,
|
|
17
18
|
} from "@earendil-works/pi-tui";
|
|
19
|
+
import { createBinariesTab } from "./binaries-tab.ts";
|
|
18
20
|
import { icon } from "./icon-catalog.ts";
|
|
19
21
|
import type { PixRuntime } from "./runtime.ts";
|
|
20
|
-
import type
|
|
22
|
+
import { type DeepPartial, deepMerge, type SectionHandle } from "./schema.ts";
|
|
21
23
|
import { collapseSection } from "./sections/collapse.ts";
|
|
22
24
|
import { compactionSection } from "./sections/compaction.ts";
|
|
23
25
|
import { gateSection } from "./sections/gate.ts";
|
|
@@ -182,8 +184,26 @@ const SETTINGS: SettingRow<unknown>[] = [
|
|
|
182
184
|
read: (v) => v.guardrails,
|
|
183
185
|
patch: (guardrails) => ({ guardrails: guardrails as "on" | "off" }),
|
|
184
186
|
}),
|
|
187
|
+
...Object.keys(prettySection.defaults.footer).map((key) => {
|
|
188
|
+
const part = key as keyof typeof prettySection.defaults.footer;
|
|
189
|
+
return row({
|
|
190
|
+
section: "Footer",
|
|
191
|
+
label: part,
|
|
192
|
+
handle: prettySection,
|
|
193
|
+
values: ["show", "hide"],
|
|
194
|
+
read: (v) => (v.footer[part] ? "show" : "hide"),
|
|
195
|
+
patch: (value) => ({ footer: { [part]: value === "show" } }),
|
|
196
|
+
});
|
|
197
|
+
}),
|
|
185
198
|
];
|
|
186
199
|
|
|
200
|
+
/** Env whose agent dir is the folder holding pix.json (binary.json lives beside it). */
|
|
201
|
+
function binariesEnv(runtime: PixRuntime): NodeJS.ProcessEnv {
|
|
202
|
+
return { ...process.env, PI_CODING_AGENT_DIR: dirname(runtime.path) };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const TABS = ["Settings", "Binaries", "Footer"] as const;
|
|
206
|
+
|
|
187
207
|
function buildSummary(runtime: PixRuntime): string {
|
|
188
208
|
const lines = [`Pix Settings (${runtime.path})`, ""];
|
|
189
209
|
let lastSection = "";
|
|
@@ -197,6 +217,12 @@ function buildSummary(runtime: PixRuntime): string {
|
|
|
197
217
|
const isDefault = value === row.read(row.handle.defaults);
|
|
198
218
|
lines.push(` ${row.label}: ${value}${isDefault ? "" : " *"}`);
|
|
199
219
|
}
|
|
220
|
+
const binaries = createBinariesTab({
|
|
221
|
+
env: binariesEnv(runtime),
|
|
222
|
+
theme: { fg: (_c, t) => t, bold: (t) => t },
|
|
223
|
+
requestRender: () => {},
|
|
224
|
+
});
|
|
225
|
+
lines.push("", "[Binaries]", ...binaries.summary());
|
|
200
226
|
return lines.join("\n");
|
|
201
227
|
}
|
|
202
228
|
|
|
@@ -236,9 +262,35 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
236
262
|
let visibleBodyLines = 1;
|
|
237
263
|
let maxBodyOffset = 0;
|
|
238
264
|
let inspectingPage = false;
|
|
265
|
+
let tab: (typeof TABS)[number] = "Settings";
|
|
266
|
+
let binaries: ReturnType<typeof createBinariesTab> | undefined;
|
|
267
|
+
const binariesTab = () => {
|
|
268
|
+
binaries ??= createBinariesTab({
|
|
269
|
+
env: binariesEnv(runtime),
|
|
270
|
+
theme,
|
|
271
|
+
requestRender: () => tui.requestRender(),
|
|
272
|
+
});
|
|
273
|
+
return binaries;
|
|
274
|
+
};
|
|
275
|
+
const tabBar = () =>
|
|
276
|
+
TABS.map((name) =>
|
|
277
|
+
name === tab
|
|
278
|
+
? theme.fg("accent", theme.bold(` ${name} `))
|
|
279
|
+
: theme.fg("muted", ` ${name} `),
|
|
280
|
+
).join(theme.fg("muted", "│"));
|
|
281
|
+
const switchTab = (direction: -1 | 1) => {
|
|
282
|
+
const next = (TABS.indexOf(tab) + direction + TABS.length) % TABS.length;
|
|
283
|
+
tab = TABS[next] ?? "Settings";
|
|
284
|
+
selected = 0;
|
|
285
|
+
bodyOffset = 0;
|
|
286
|
+
inspectingPage = false;
|
|
287
|
+
if (tab === "Binaries") binariesTab().refresh();
|
|
288
|
+
};
|
|
239
289
|
|
|
290
|
+
const rows = () =>
|
|
291
|
+
SETTINGS.filter((r) => (r.section === "Footer") === (tab === "Footer"));
|
|
240
292
|
const cycle = (direction: -1 | 1) => {
|
|
241
|
-
const row =
|
|
293
|
+
const row = rows()[selected];
|
|
242
294
|
if (!row) return;
|
|
243
295
|
// Functional updater: the runtime queue serializes writes, and the
|
|
244
296
|
// callback sees the latest committed value — so rapid presses each
|
|
@@ -258,7 +310,7 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
258
310
|
const next = (cur + direction + row.values.length) % row.values.length;
|
|
259
311
|
const val = row.values[next];
|
|
260
312
|
if (val === undefined) return state;
|
|
261
|
-
return
|
|
313
|
+
return deepMerge(state, row.patch(val) as DeepPartial<SettingsState>);
|
|
262
314
|
};
|
|
263
315
|
// Re-render AFTER the commit lands; the synchronous render in
|
|
264
316
|
// handleInput fires before the queued write and shows a stale value.
|
|
@@ -268,18 +320,53 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
268
320
|
.catch(() => ui.notify(`pix: failed to update ${row.label}`, "error"));
|
|
269
321
|
};
|
|
270
322
|
const move = (direction: -1 | 1) => {
|
|
271
|
-
selected = (selected + direction +
|
|
323
|
+
selected = (selected + direction + rows().length) % rows().length;
|
|
272
324
|
inspectingPage = false;
|
|
273
325
|
};
|
|
274
326
|
|
|
275
327
|
return {
|
|
276
328
|
render: (width: number) => {
|
|
277
|
-
const
|
|
329
|
+
const frame = {
|
|
330
|
+
width,
|
|
331
|
+
maxHeight: modalHeight(
|
|
332
|
+
tui.terminal?.rows,
|
|
333
|
+
runtime.get(prettySection).maxRenderHeight,
|
|
334
|
+
),
|
|
335
|
+
title: `${icon("settings")} Pix Settings`,
|
|
336
|
+
titleColor: (s: string) => theme.fg("accent", theme.bold(s)),
|
|
337
|
+
color: (s: string) => theme.fg("accent", s),
|
|
338
|
+
bg: (s: string) => theme.bg("customMessageBg", s),
|
|
339
|
+
};
|
|
340
|
+
const tabGuide = guide("tab", "switch tab");
|
|
341
|
+
if (tab === "Binaries") {
|
|
342
|
+
const bt = binariesTab();
|
|
343
|
+
const view = bt.view(width);
|
|
344
|
+
const hints = view.footer.map(([key, action]) => guide(key, action)).join(guideSep);
|
|
345
|
+
const result = frameModal({
|
|
346
|
+
...frame,
|
|
347
|
+
header: [tabBar(), "", ...view.header],
|
|
348
|
+
body: view.body,
|
|
349
|
+
footer: [
|
|
350
|
+
"",
|
|
351
|
+
bt.editing
|
|
352
|
+
? hints
|
|
353
|
+
: hints + guideSep + tabGuide + guideSep + guide("esc", "close"),
|
|
354
|
+
],
|
|
355
|
+
bodyOffset,
|
|
356
|
+
selectedBodyLine: inspectingPage ? undefined : view.selectedBodyLine,
|
|
357
|
+
});
|
|
358
|
+
bodyOffset = result.bodyOffset;
|
|
359
|
+
visibleBodyLines = Math.max(1, result.visibleBodyLines);
|
|
360
|
+
maxBodyOffset = result.maxBodyOffset;
|
|
361
|
+
return result.lines;
|
|
362
|
+
}
|
|
363
|
+
const settings = rows();
|
|
364
|
+
const labelW = Math.max(...settings.map((r) => r.label.length));
|
|
278
365
|
const body: string[] = [];
|
|
279
366
|
const settingBodyLines: number[] = [];
|
|
280
367
|
let lastSection = "";
|
|
281
|
-
for (let i = 0; i <
|
|
282
|
-
const row =
|
|
368
|
+
for (let i = 0; i < settings.length; i++) {
|
|
369
|
+
const row = settings[i];
|
|
283
370
|
if (!row) continue;
|
|
284
371
|
if (row.section !== lastSection) {
|
|
285
372
|
if (lastSection) body.push("");
|
|
@@ -295,14 +382,8 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
295
382
|
body.push(`${cursor} ${label} ${theme.fg(isDefault ? "dim" : "success", value)}`);
|
|
296
383
|
}
|
|
297
384
|
const result = frameModal({
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
tui.terminal?.rows,
|
|
301
|
-
runtime.get(prettySection).maxRenderHeight,
|
|
302
|
-
),
|
|
303
|
-
title: `${icon("settings")} Pix Settings`,
|
|
304
|
-
titleColor: (s: string) => theme.fg("accent", theme.bold(s)),
|
|
305
|
-
header: [""],
|
|
385
|
+
...frame,
|
|
386
|
+
header: [tabBar(), ""],
|
|
306
387
|
body,
|
|
307
388
|
footer: [
|
|
308
389
|
"",
|
|
@@ -312,12 +393,12 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
312
393
|
guideSep +
|
|
313
394
|
guide("PgUp/PgDn", "inspect") +
|
|
314
395
|
guideSep +
|
|
396
|
+
tabGuide +
|
|
397
|
+
guideSep +
|
|
315
398
|
guide("esc", "close"),
|
|
316
399
|
],
|
|
317
400
|
bodyOffset,
|
|
318
401
|
selectedBodyLine: inspectingPage ? undefined : settingBodyLines[selected],
|
|
319
|
-
color: (s: string) => theme.fg("accent", s),
|
|
320
|
-
bg: (s: string) => theme.bg("customMessageBg", s),
|
|
321
402
|
});
|
|
322
403
|
bodyOffset = result.bodyOffset;
|
|
323
404
|
visibleBodyLines = Math.max(1, result.visibleBodyLines);
|
|
@@ -326,6 +407,24 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
326
407
|
},
|
|
327
408
|
invalidate: () => {},
|
|
328
409
|
handleInput: (data: string) => {
|
|
410
|
+
if (tab === "Binaries" && binariesTab().editing) {
|
|
411
|
+
// The filter input takes ↑↓ to move through its matches.
|
|
412
|
+
const nav = !binariesTab().editingPath;
|
|
413
|
+
binariesTab().handleInput(data, {
|
|
414
|
+
up: nav && kb.matches(data, "tui.select.up"),
|
|
415
|
+
down: nav && kb.matches(data, "tui.select.down"),
|
|
416
|
+
enter: false,
|
|
417
|
+
});
|
|
418
|
+
inspectingPage = false;
|
|
419
|
+
tui.requestRender();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const shiftTab = matchesKey(data, Key.shift(Key.tab));
|
|
423
|
+
if (shiftTab || matchesKey(data, Key.tab)) {
|
|
424
|
+
switchTab(shiftTab ? -1 : 1);
|
|
425
|
+
tui.requestRender();
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
329
428
|
const pageUp = kb?.matches(data, "tui.select.pageUp") || matchesKey(data, Key.pageUp);
|
|
330
429
|
const pageDown =
|
|
331
430
|
kb?.matches(data, "tui.select.pageDown") || matchesKey(data, Key.pageDown);
|
|
@@ -340,9 +439,25 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
340
439
|
// for letters and special keys alike — raw string compares like
|
|
341
440
|
// `data === "k"` silently fail under the Kitty keyboard protocol.
|
|
342
441
|
if (kb.matches(data, "tui.select.cancel")) {
|
|
442
|
+
if (tab === "Binaries" && binariesTab().clearFilter()) {
|
|
443
|
+
tui.requestRender();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
343
446
|
done(null);
|
|
344
447
|
return;
|
|
345
448
|
}
|
|
449
|
+
if (tab === "Binaries") {
|
|
450
|
+
const handled = binariesTab().handleInput(data, {
|
|
451
|
+
up: kb.matches(data, "tui.select.up"),
|
|
452
|
+
down: kb.matches(data, "tui.select.down"),
|
|
453
|
+
enter: kb.matches(data, "tui.select.confirm"),
|
|
454
|
+
});
|
|
455
|
+
if (handled) {
|
|
456
|
+
inspectingPage = false;
|
|
457
|
+
tui.requestRender();
|
|
458
|
+
}
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
346
461
|
if (kb.matches(data, "tui.select.up")) move(-1);
|
|
347
462
|
else if (kb.matches(data, "tui.select.down")) move(1);
|
|
348
463
|
else if (matchesKey(data, Key.left)) cycle(-1);
|
package/src/platform.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* platform.ts — one host description for binary selection and OS branches.
|
|
3
|
+
*
|
|
4
|
+
* Pure except for two cached probes (glibc via `process.report`, WSL via
|
|
5
|
+
* `/proc/version`); every input is injectable so tests cover all hosts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
|
|
10
|
+
export type HostOs = "win32" | "linux" | "darwin" | "android";
|
|
11
|
+
export type HostArch = "x64" | "arm64";
|
|
12
|
+
|
|
13
|
+
export interface HostPlatform {
|
|
14
|
+
os: HostOs;
|
|
15
|
+
arch: HostArch;
|
|
16
|
+
/** Linux C library; undefined elsewhere. */
|
|
17
|
+
libc?: "glibc" | "musl";
|
|
18
|
+
/** Linux kernel under Windows Subsystem for Linux. */
|
|
19
|
+
wsl: boolean;
|
|
20
|
+
/** Termux on Android. */
|
|
21
|
+
termux: boolean;
|
|
22
|
+
/** Executable suffix. */
|
|
23
|
+
exe: "" | ".exe";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface HostProbe {
|
|
27
|
+
platform?: NodeJS.Platform;
|
|
28
|
+
arch?: string;
|
|
29
|
+
env?: NodeJS.ProcessEnv;
|
|
30
|
+
/** glibc runtime version, or undefined on musl. */
|
|
31
|
+
glibcVersion?: () => string | undefined;
|
|
32
|
+
/** Contents of `/proc/version`, or undefined when unreadable. */
|
|
33
|
+
procVersion?: () => string | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function defaultGlibc(): string | undefined {
|
|
37
|
+
try {
|
|
38
|
+
const report = process.report?.getReport() as { header?: { glibcVersionRuntime?: string } };
|
|
39
|
+
return report?.header?.glibcVersionRuntime;
|
|
40
|
+
} catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function defaultProcVersion(): string | undefined {
|
|
46
|
+
try {
|
|
47
|
+
return readFileSync("/proc/version", "utf-8");
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Describe the current host (or an injected one). */
|
|
54
|
+
export function hostPlatform(probe: HostProbe = {}): HostPlatform {
|
|
55
|
+
const platform = probe.platform ?? process.platform;
|
|
56
|
+
const env = probe.env ?? process.env;
|
|
57
|
+
const arch: HostArch = (probe.arch ?? process.arch) === "arm64" ? "arm64" : "x64";
|
|
58
|
+
const termux = Boolean(env.TERMUX_VERSION) || platform === "android";
|
|
59
|
+
if (platform === "win32") return { os: "win32", arch, wsl: false, termux: false, exe: ".exe" };
|
|
60
|
+
if (platform === "darwin") return { os: "darwin", arch, wsl: false, termux: false, exe: "" };
|
|
61
|
+
if (termux) return { os: "android", arch, wsl: false, termux: true, exe: "" };
|
|
62
|
+
const glibc = (probe.glibcVersion ?? defaultGlibc)();
|
|
63
|
+
const wsl =
|
|
64
|
+
Boolean(env.WSL_DISTRO_NAME || env.WSLENV) ||
|
|
65
|
+
/microsoft|wsl/i.test((probe.procVersion ?? defaultProcVersion)() ?? "");
|
|
66
|
+
return { os: "linux", arch, libc: glibc ? "glibc" : "musl", wsl, termux: false, exe: "" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let cached: HostPlatform | undefined;
|
|
70
|
+
|
|
71
|
+
/** Cached {@link hostPlatform} for the running process. */
|
|
72
|
+
export function currentPlatform(): HostPlatform {
|
|
73
|
+
cached ??= hostPlatform();
|
|
74
|
+
return cached;
|
|
75
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
* after a lazy load; writes are serialized and atomic.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
10
9
|
import { DiagnosticSink } from "./diagnostics.ts";
|
|
11
10
|
import {
|
|
12
11
|
type ConfigChange,
|
|
@@ -18,8 +17,9 @@ import {
|
|
|
18
17
|
type SubscribeOptions,
|
|
19
18
|
} from "./events.ts";
|
|
20
19
|
import { importOptimizerSidecar, migrate } from "./migrations.ts";
|
|
20
|
+
import { agentDir } from "./paths.ts";
|
|
21
21
|
import {
|
|
22
|
-
|
|
22
|
+
ConfigParseError,
|
|
23
23
|
FileStorage,
|
|
24
24
|
parseRawDocument,
|
|
25
25
|
type StorageAdapter,
|
|
@@ -99,7 +99,7 @@ class RuntimeImpl implements PixRuntime {
|
|
|
99
99
|
private readOnly = false;
|
|
100
100
|
|
|
101
101
|
constructor(adapters: RuntimeAdapters = {}) {
|
|
102
|
-
this.agentDir = adapters.agentDir ??
|
|
102
|
+
this.agentDir = adapters.agentDir ?? agentDir();
|
|
103
103
|
this.storage = adapters.storage ?? new FileStorage(this.agentDir);
|
|
104
104
|
this.registry = adapters.registry ?? new SectionRegistry();
|
|
105
105
|
}
|
|
@@ -139,16 +139,45 @@ class RuntimeImpl implements PixRuntime {
|
|
|
139
139
|
try {
|
|
140
140
|
doc = parseRawDocument(this.storage.readRaw());
|
|
141
141
|
} catch (err) {
|
|
142
|
-
this.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
142
|
+
if (err instanceof ConfigParseError) this.parseFailed(err);
|
|
143
|
+
else
|
|
144
|
+
this.sink.push({
|
|
145
|
+
code: "READ_FAILED",
|
|
146
|
+
severity: "warning",
|
|
147
|
+
message: "config read failed; using defaults",
|
|
148
|
+
cause: err,
|
|
149
|
+
});
|
|
148
150
|
}
|
|
149
151
|
return this.publish(this.resolve(doc));
|
|
150
152
|
}
|
|
151
153
|
|
|
154
|
+
/** A malformed pix.json stays untouched: defaults apply, writes are refused. */
|
|
155
|
+
private parseFailed(err: ConfigParseError, path?: string): void {
|
|
156
|
+
this.sink.push({
|
|
157
|
+
code: "PARSE_ERROR",
|
|
158
|
+
severity: "error",
|
|
159
|
+
path,
|
|
160
|
+
message: `${this.storage.path}: ${err.message}. File left unchanged; fix it to save settings.`,
|
|
161
|
+
cause: err,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Migration + sidecar import for one raw file read. Pure except `ctx`. */
|
|
166
|
+
private planInit(raw: string | undefined, ctx: ParseContext) {
|
|
167
|
+
const doc = parseRawDocument(raw);
|
|
168
|
+
const migrated = migrate(doc, ctx);
|
|
169
|
+
const readOnly = migrated.document.$version !== CONFIG_FORMAT_VERSION;
|
|
170
|
+
let needsWrite = migrated.changed;
|
|
171
|
+
let archive: (() => void) | undefined;
|
|
172
|
+
if (!readOnly) {
|
|
173
|
+
const sidecar = importOptimizerSidecar(migrated.document, this.agentDir, ctx);
|
|
174
|
+
if (sidecar.changed) needsWrite = true;
|
|
175
|
+
archive = sidecar.archive;
|
|
176
|
+
}
|
|
177
|
+
// Do not normalize a future-version file.
|
|
178
|
+
return { working: readOnly ? doc : migrated.document, readOnly, needsWrite, archive };
|
|
179
|
+
}
|
|
180
|
+
|
|
152
181
|
snapshot(): ConfigSnapshot {
|
|
153
182
|
return this.current ?? this.lazyLoad();
|
|
154
183
|
}
|
|
@@ -182,36 +211,38 @@ class RuntimeImpl implements PixRuntime {
|
|
|
182
211
|
const hadLazy = this.current !== null;
|
|
183
212
|
const previous = this.current;
|
|
184
213
|
this.storage.ensureDir();
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
214
|
+
let working: RawDocument = {};
|
|
215
|
+
let needsWrite = false;
|
|
216
|
+
try {
|
|
217
|
+
// Unlocked probe: most sessions need no write, so they never take the lock.
|
|
218
|
+
let plan = this.planInit(this.storage.readRaw(), this.parseContext());
|
|
219
|
+
this.readOnly = plan.readOnly;
|
|
220
|
+
working = plan.working;
|
|
221
|
+
if (plan.needsWrite && !plan.readOnly) {
|
|
222
|
+
try {
|
|
223
|
+
// Re-plan under the lock so a concurrent writer's change survives.
|
|
224
|
+
this.storage.transact((raw) => {
|
|
225
|
+
plan = this.planInit(raw, { diagnostic: () => {} });
|
|
226
|
+
working = plan.working;
|
|
227
|
+
needsWrite = plan.needsWrite && !plan.readOnly;
|
|
228
|
+
return needsWrite ? serializeRawDocument(plan.working) : undefined;
|
|
229
|
+
});
|
|
230
|
+
if (needsWrite) plan.archive?.();
|
|
231
|
+
} catch (err) {
|
|
232
|
+
needsWrite = false;
|
|
233
|
+
if (err instanceof ConfigParseError) throw err;
|
|
234
|
+
this.sink.push({
|
|
235
|
+
code: "WRITE_FAILED",
|
|
236
|
+
severity: "error",
|
|
237
|
+
message: "initial migration write failed",
|
|
238
|
+
cause: err,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
212
241
|
}
|
|
213
|
-
}
|
|
214
|
-
|
|
242
|
+
} catch (err) {
|
|
243
|
+
if (!(err instanceof ConfigParseError)) throw err;
|
|
244
|
+
this.parseFailed(err);
|
|
245
|
+
working = {};
|
|
215
246
|
}
|
|
216
247
|
|
|
217
248
|
const values = this.resolve(working);
|
|
@@ -242,6 +273,10 @@ class RuntimeImpl implements PixRuntime {
|
|
|
242
273
|
}
|
|
243
274
|
return snapshot;
|
|
244
275
|
});
|
|
276
|
+
// A failed init (unreadable agent dir) must not stay cached: the next call retries.
|
|
277
|
+
this.initPromise.catch(() => {
|
|
278
|
+
this.initPromise = null;
|
|
279
|
+
});
|
|
245
280
|
return this.initPromise;
|
|
246
281
|
}
|
|
247
282
|
|
|
@@ -267,10 +302,6 @@ class RuntimeImpl implements PixRuntime {
|
|
|
267
302
|
return stripDefaults(copy, defaults) > 0 ? copy : undefined;
|
|
268
303
|
}
|
|
269
304
|
|
|
270
|
-
private persist(doc: RawDocument): void {
|
|
271
|
-
this.storage.writeAtomic(serializeRawDocument(doc));
|
|
272
|
-
}
|
|
273
|
-
|
|
274
305
|
private changedPaths(prev: ConfigSnapshot, next: ConfigSnapshot): string[] {
|
|
275
306
|
const out: string[] = [];
|
|
276
307
|
for (const section of this.registry.all()) {
|
|
@@ -339,49 +370,56 @@ class RuntimeImpl implements PixRuntime {
|
|
|
339
370
|
}
|
|
340
371
|
|
|
341
372
|
const previous = this.snapshot();
|
|
342
|
-
const base = parseRawDocument(this.storage.readRaw());
|
|
343
373
|
const ctx = this.parseContext();
|
|
374
|
+
let values: Map<string, unknown> | undefined;
|
|
344
375
|
|
|
345
|
-
// Re-resolve all sections from the latest on-disk doc so unknown fields
|
|
346
|
-
// and sibling sections survive.
|
|
347
|
-
const values = new Map<string, unknown>();
|
|
348
|
-
for (const s of this.registry.all()) values.set(s.key, s.parse(base[s.key], ctx));
|
|
349
|
-
|
|
350
|
-
// Guard against a version-skewed second runtime copy whose registry
|
|
351
|
-
// lacks this section (e.g. an old npm copy handling a new handle). The
|
|
352
|
-
// functional updater would otherwise crash the whole process on
|
|
353
|
-
// `undefined`. Fall back to the handle's own parse of the raw doc.
|
|
354
|
-
const currentValue = (
|
|
355
|
-
values.has(section.key)
|
|
356
|
-
? values.get(section.key)
|
|
357
|
-
: section.__section.parse(base[section.key], ctx)
|
|
358
|
-
) as Readonly<T>;
|
|
359
|
-
const mergedValue =
|
|
360
|
-
typeof updater === "function"
|
|
361
|
-
? (updater as (c: Readonly<T>) => T)(currentValue)
|
|
362
|
-
: deepMerge(currentValue as T, updater);
|
|
363
|
-
|
|
364
|
-
// Re-validate through the section's own parse so a bad patch (NaN,
|
|
365
|
-
// Infinity, wrong type) can never enter the live snapshot.
|
|
366
|
-
const nextValue = section.__section.parse(mergedValue, ctx) as T;
|
|
367
|
-
|
|
368
|
-
if (JSON.stringify(currentValue) === JSON.stringify(nextValue)) return undefined;
|
|
369
|
-
values.set(section.key, nextValue);
|
|
370
|
-
|
|
371
|
-
const doc = this.buildRawFromValues(values, base);
|
|
372
376
|
try {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
this.
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
377
|
+
// Read, patch, and write under one lock hold: a second Pi process cannot
|
|
378
|
+
// write between our read and our write, so its change is not lost.
|
|
379
|
+
this.storage.transact((raw) => {
|
|
380
|
+
const base = parseRawDocument(raw);
|
|
381
|
+
|
|
382
|
+
// Re-resolve all sections from the latest on-disk doc so unknown fields
|
|
383
|
+
// and sibling sections survive.
|
|
384
|
+
const next = new Map<string, unknown>();
|
|
385
|
+
for (const s of this.registry.all()) next.set(s.key, s.parse(base[s.key], ctx));
|
|
386
|
+
|
|
387
|
+
// Guard against a version-skewed second runtime copy whose registry
|
|
388
|
+
// lacks this section (e.g. an old npm copy handling a new handle). The
|
|
389
|
+
// functional updater would otherwise crash the whole process on
|
|
390
|
+
// `undefined`. Fall back to the handle's own parse of the raw doc.
|
|
391
|
+
const currentValue = (
|
|
392
|
+
next.has(section.key)
|
|
393
|
+
? next.get(section.key)
|
|
394
|
+
: section.__section.parse(base[section.key], ctx)
|
|
395
|
+
) as Readonly<T>;
|
|
396
|
+
const mergedValue =
|
|
397
|
+
typeof updater === "function"
|
|
398
|
+
? (updater as (c: Readonly<T>) => T)(currentValue)
|
|
399
|
+
: deepMerge(currentValue as T, updater);
|
|
400
|
+
|
|
401
|
+
// Re-validate through the section's own parse so a bad patch (NaN,
|
|
402
|
+
// Infinity, wrong type) can never enter the live snapshot.
|
|
403
|
+
const nextValue = section.__section.parse(mergedValue, ctx) as T;
|
|
404
|
+
|
|
405
|
+
if (JSON.stringify(currentValue) === JSON.stringify(nextValue)) return undefined;
|
|
406
|
+
next.set(section.key, nextValue);
|
|
407
|
+
values = next;
|
|
408
|
+
return serializeRawDocument(this.buildRawFromValues(next, base));
|
|
381
409
|
});
|
|
382
|
-
|
|
410
|
+
} catch (err) {
|
|
411
|
+
if (err instanceof ConfigParseError) this.parseFailed(err, section.key);
|
|
412
|
+
else
|
|
413
|
+
this.sink.push({
|
|
414
|
+
code: "WRITE_FAILED",
|
|
415
|
+
severity: "error",
|
|
416
|
+
path: section.key,
|
|
417
|
+
message: "config write failed; snapshot unchanged",
|
|
418
|
+
cause: err,
|
|
419
|
+
});
|
|
383
420
|
return undefined;
|
|
384
421
|
}
|
|
422
|
+
if (!values) return undefined;
|
|
385
423
|
|
|
386
424
|
const snapshot = this.publish(values);
|
|
387
425
|
const change: ConfigChange = {
|
|
@@ -401,7 +439,15 @@ class RuntimeImpl implements PixRuntime {
|
|
|
401
439
|
reload(options: ReloadOptions = {}): Promise<ConfigChange | undefined> {
|
|
402
440
|
return this.queue.run(async () => {
|
|
403
441
|
const previous = this.snapshot();
|
|
404
|
-
|
|
442
|
+
let doc: RawDocument;
|
|
443
|
+
try {
|
|
444
|
+
doc = parseRawDocument(this.storage.readRaw());
|
|
445
|
+
} catch (err) {
|
|
446
|
+
// Keep the last good snapshot. Do not reset live settings to defaults.
|
|
447
|
+
if (!(err instanceof ConfigParseError)) throw err;
|
|
448
|
+
this.parseFailed(err);
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
405
451
|
const values = this.resolve(doc);
|
|
406
452
|
const snapshot = this.publish(values);
|
|
407
453
|
const changed = this.changedPaths(previous, snapshot);
|