@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/core",
3
- "version": "2.7.0",
3
+ "version": "2.7.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -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
+ });
@@ -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
- /** Read the full settings.json, returning empty object if missing/corrupt. */
8
- function readSettings(): Record<string, unknown> {
9
- if (!existsSync(SETTINGS_PATH)) return {};
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 JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
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
+ }