@pi-archimedes/core 2.7.0 → 2.7.1
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/package.json +1 -1
- package/src/settings-io.test.ts +112 -1
- package/src/settings-io.ts +67 -4
package/package.json
CHANGED
package/src/settings-io.test.ts
CHANGED
|
@@ -17,7 +17,7 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
|
17
17
|
}));
|
|
18
18
|
|
|
19
19
|
// Import after mocks are set up
|
|
20
|
-
const { loadConfig, saveConfig, removeConfig, isConfigEnabled, setConfigEnabled } = await import("./settings-io.js");
|
|
20
|
+
const { loadConfig, saveConfig, removeConfig, isConfigEnabled, setConfigEnabled, updateConfig } = await import("./settings-io.js");
|
|
21
21
|
|
|
22
22
|
describe("removeConfig", () => {
|
|
23
23
|
beforeEach(() => {
|
|
@@ -242,3 +242,114 @@ describe("saveConfig", () => {
|
|
|
242
242
|
expect(fs.existsSync(settingsPath + ".tmp")).toBe(false);
|
|
243
243
|
});
|
|
244
244
|
});
|
|
245
|
+
|
|
246
|
+
describe("updateConfig", () => {
|
|
247
|
+
const settingsPath = () => join(tempDir, "settings.json");
|
|
248
|
+
|
|
249
|
+
beforeEach(() => {
|
|
250
|
+
const p = settingsPath();
|
|
251
|
+
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
afterEach(() => {
|
|
255
|
+
const p = settingsPath();
|
|
256
|
+
const tmpP = p + ".tmp";
|
|
257
|
+
try { fs.unlinkSync(p); } catch { /* ignore */ }
|
|
258
|
+
try { fs.unlinkSync(tmpP); } catch { /* ignore */ }
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("applies mutation onto defaults when settings.json does not exist", () => {
|
|
262
|
+
const result = updateConfig(
|
|
263
|
+
"test.ns",
|
|
264
|
+
{ done: false, count: 0 },
|
|
265
|
+
(cfg) => ({ ...cfg, done: true }),
|
|
266
|
+
);
|
|
267
|
+
expect(result).toEqual({ done: true, count: 0 });
|
|
268
|
+
const data = JSON.parse(fs.readFileSync(settingsPath(), "utf-8"));
|
|
269
|
+
expect(data["test.ns"]).toEqual({ done: true, count: 0 });
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("preserves sibling namespaces AND other keys of the target namespace", () => {
|
|
273
|
+
fs.writeFileSync(
|
|
274
|
+
settingsPath(),
|
|
275
|
+
JSON.stringify({
|
|
276
|
+
"test.ns": { done: false, extra: 99 },
|
|
277
|
+
"other.ns": { sibling: "value" },
|
|
278
|
+
}),
|
|
279
|
+
"utf-8",
|
|
280
|
+
);
|
|
281
|
+
const result = updateConfig(
|
|
282
|
+
"test.ns",
|
|
283
|
+
{ done: false, extra: 0 },
|
|
284
|
+
(cfg) => ({ ...cfg, done: true }),
|
|
285
|
+
);
|
|
286
|
+
expect(result).toEqual({ done: true, extra: 99 });
|
|
287
|
+
const data = JSON.parse(fs.readFileSync(settingsPath(), "utf-8"));
|
|
288
|
+
expect(data["test.ns"]).toEqual({ done: true, extra: 99 });
|
|
289
|
+
expect(data["other.ns"]).toEqual({ sibling: "value" });
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("concurrent-change detection: detects a mid-mutation external write and retries; final file contains both the external key and the mutated value", () => {
|
|
293
|
+
fs.writeFileSync(
|
|
294
|
+
settingsPath(),
|
|
295
|
+
JSON.stringify({ "test.ns": { done: false } }),
|
|
296
|
+
"utf-8",
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
let mutateCallCount = 0;
|
|
300
|
+
const result = updateConfig(
|
|
301
|
+
"test.ns",
|
|
302
|
+
{ done: false },
|
|
303
|
+
(cfg) => {
|
|
304
|
+
mutateCallCount += 1;
|
|
305
|
+
// On the first call, simulate a concurrent process writing an external key
|
|
306
|
+
// between the `before` read and the `after` read. This change must be
|
|
307
|
+
// detected by the optimistic re-read check, triggering a retry.
|
|
308
|
+
if (mutateCallCount === 1) {
|
|
309
|
+
const raw = JSON.parse(fs.readFileSync(settingsPath(), "utf-8"));
|
|
310
|
+
raw["concurrent.ns"] = { injected: true };
|
|
311
|
+
fs.writeFileSync(settingsPath(), JSON.stringify(raw), "utf-8");
|
|
312
|
+
}
|
|
313
|
+
return { ...cfg, done: true };
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
expect(mutateCallCount).toBeGreaterThan(1);
|
|
318
|
+
expect(result).toEqual({ done: true });
|
|
319
|
+
const data = JSON.parse(fs.readFileSync(settingsPath(), "utf-8"));
|
|
320
|
+
// Retry recomputed from fresh state — both the external key AND the mutated value survive
|
|
321
|
+
expect(data["test.ns"]).toEqual({ done: true });
|
|
322
|
+
expect(data["concurrent.ns"]).toEqual({ injected: true });
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("persistent concurrent writer: after maxAttempts the update still completes (last-writer-wins, no crash)", () => {
|
|
326
|
+
fs.writeFileSync(
|
|
327
|
+
settingsPath(),
|
|
328
|
+
JSON.stringify({ "test.ns": { done: false } }),
|
|
329
|
+
"utf-8",
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
let mutateCallCount = 0;
|
|
333
|
+
// mutate always writes the external key — simulates a persistent concurrent writer
|
|
334
|
+
const result = updateConfig(
|
|
335
|
+
"test.ns",
|
|
336
|
+
{ done: false },
|
|
337
|
+
(cfg) => {
|
|
338
|
+
mutateCallCount += 1;
|
|
339
|
+
// Always write a concurrent change to force re-read on every attempt
|
|
340
|
+
const raw = JSON.parse(fs.readFileSync(settingsPath(), "utf-8"));
|
|
341
|
+
raw["concurrent.ns"] = { injected: mutateCallCount };
|
|
342
|
+
fs.writeFileSync(settingsPath(), JSON.stringify(raw), "utf-8");
|
|
343
|
+
return { ...cfg, done: true };
|
|
344
|
+
},
|
|
345
|
+
3, // maxAttempts
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
// Must complete (not loop forever or throw)
|
|
349
|
+
expect(mutateCallCount).toBe(3); // saturated at maxAttempts
|
|
350
|
+
expect(result).toEqual({ done: true }); // flag value persisted
|
|
351
|
+
const data = JSON.parse(fs.readFileSync(settingsPath(), "utf-8"));
|
|
352
|
+
expect(data["test.ns"]).toEqual({ done: true }); // flag survived
|
|
353
|
+
expect(data["concurrent.ns"]).toBeDefined(); // siblings survived
|
|
354
|
+
});
|
|
355
|
+
});
|
package/src/settings-io.ts
CHANGED
|
@@ -4,16 +4,35 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
|
4
4
|
|
|
5
5
|
const SETTINGS_PATH = join(getAgentDir(), "settings.json");
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Read settings.json as a raw string. Returns null when the file is absent or
|
|
9
|
+
* unreadable (EISDIR, EACCES, etc.) — callers treat null the same as an empty
|
|
10
|
+
* file (i.e. no prior settings).
|
|
11
|
+
*/
|
|
12
|
+
function readRawSettings(): string | null {
|
|
13
|
+
if (!existsSync(SETTINGS_PATH)) return null;
|
|
10
14
|
try {
|
|
11
|
-
return
|
|
15
|
+
return readFileSync(SETTINGS_PATH, "utf-8");
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Parse a raw settings string, returning empty object on null/corrupt input. */
|
|
22
|
+
function parseSettings(raw: string | null): Record<string, unknown> {
|
|
23
|
+
if (raw === null) return {};
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(raw) as Record<string, unknown>;
|
|
12
26
|
} catch {
|
|
13
27
|
return {};
|
|
14
28
|
}
|
|
15
29
|
}
|
|
16
30
|
|
|
31
|
+
/** Read the full settings.json, returning empty object if missing/corrupt. */
|
|
32
|
+
function readSettings(): Record<string, unknown> {
|
|
33
|
+
return parseSettings(readRawSettings());
|
|
34
|
+
}
|
|
35
|
+
|
|
17
36
|
/**
|
|
18
37
|
* Load a config section from settings.json, merged with defaults.
|
|
19
38
|
*/
|
|
@@ -97,3 +116,47 @@ export function setConfigEnabled(namespace: string, enabled: boolean): void {
|
|
|
97
116
|
saveConfig(namespace, cfg);
|
|
98
117
|
}
|
|
99
118
|
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Concurrency-safe read-modify-write for one settings namespace.
|
|
122
|
+
*
|
|
123
|
+
* Uses an optimistic re-read check: the settings file is read before and after
|
|
124
|
+
* the `mutate` callback runs. If the file changed during that window (indicating
|
|
125
|
+
* a concurrent writer, e.g. another TUI session running /plugins toggle), the
|
|
126
|
+
* attempt is discarded and the loop restarts from a fresh read — up to
|
|
127
|
+
* `maxAttempts` times.
|
|
128
|
+
*
|
|
129
|
+
* Sibling namespaces always benefit from `saveConfig`’s own fresh read inside
|
|
130
|
+
* the atomic write, so they are never clobbered regardless of retries.
|
|
131
|
+
*
|
|
132
|
+
* The residual window between the final `after` check and `saveConfig`’s
|
|
133
|
+
* internal read is last-writer-wins; the optimistic check handles all
|
|
134
|
+
* detectable races (changes that happen during the `mutate` call itself).
|
|
135
|
+
*
|
|
136
|
+
* When the maximum attempt count is reached without a clean window (persistent
|
|
137
|
+
* concurrent writer), the last computed value is still written — no crash, no
|
|
138
|
+
* unbounded loop.
|
|
139
|
+
*/
|
|
140
|
+
export function updateConfig<T extends object>(
|
|
141
|
+
namespace: string,
|
|
142
|
+
defaults: T,
|
|
143
|
+
mutate: (cfg: T) => T,
|
|
144
|
+
maxAttempts = 3,
|
|
145
|
+
): T {
|
|
146
|
+
let last = { ...defaults } as T;
|
|
147
|
+
for (let attempt = 1; ; attempt++) {
|
|
148
|
+
const before = readRawSettings();
|
|
149
|
+
const parsed = parseSettings(before);
|
|
150
|
+
const current = { ...defaults, ...(parsed[namespace] ?? {}) } as T;
|
|
151
|
+
const next = mutate(current);
|
|
152
|
+
const after = readRawSettings();
|
|
153
|
+
if (before !== after && attempt < maxAttempts) {
|
|
154
|
+
// Settings file changed during our update window (concurrent writer) —
|
|
155
|
+
// recompute from the fresh state on the next iteration.
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
last = next;
|
|
159
|
+
saveConfig(namespace, next);
|
|
160
|
+
return last;
|
|
161
|
+
}
|
|
162
|
+
}
|