@lmzhen/dsh-evolution-state-json 0.3.67 → 0.3.69

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.
Files changed (2) hide show
  1. package/lib/index.js +80 -36
  2. package/package.json +7 -7
package/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
2
  import { evolutionHome, makeSerialQueue, transactIo } from "@lmzhen/dsh-evolution-core";
3
- import { CURATOR_STATE_FILE, CURATOR_STATE_KEY, CURATOR_STATE_TABLE, PENDING_ARCHIVE_BAK_FILE, PENDING_ARCHIVE_FILE, PENDING_LEGACY_FILE, PENDING_RESOLVED_CAP, PENDING_STATE_FILE, PENDING_TABLE, PROVIDER_JSON, REVIEW_STATE_FILE, REVIEW_STATE_SESSION_CAP, REVIEW_STATE_TABLE, assertCloneable, canClaimPending, canResolvePending, recordIssue, releasedStatus } from "@lmzhen/dsh-evolution-state-storage";
4
- import { isAbsolute, join } from "node:path";
3
+ import { CURATOR_STATE_FILE, CURATOR_STATE_KEY, CURATOR_STATE_TABLE, PENDING_ARCHIVE_BAK_FILE, PENDING_ARCHIVE_FILE, PENDING_LEGACY_FILE, PENDING_RESOLVED_CAP, PENDING_STATE_FILE, PENDING_TABLE, PROVIDER_JSON, REVIEW_STATE_FILE, REVIEW_STATE_SESSION_CAP, REVIEW_STATE_TABLE, assertCloneable, canClaimPending, canResolvePending, recordIssue, releasedStatus, selectPendingOverflow, selectSessionOverflow } from "@lmzhen/dsh-evolution-state-storage";
4
+ import { basename, dirname, isAbsolute, join } from "node:path";
5
5
  //#region lib/types/index.js
6
6
  /**
7
7
  * JSON-file evolution state provider over the IO seam.
@@ -62,18 +62,42 @@ const QUARANTINE_ERROR_NAME = "EvolutionStateCorruptFile";
62
62
  * DIFFERENT payload gets a stamped sibling so the earlier rescue copy is never
63
63
  * overwritten. The node backend's 7-day `.corrupt` sweep bounds the set. */
64
64
  async function quarantineTarget(io, base, content) {
65
- if (!await io().exists(base).catch(() => false)) return base;
66
- return await io().readText(base).catch(() => null) === content ? base : `${base}.${Date.now()}`;
65
+ if (!await io().exists(base).catch(() => false)) return {
66
+ dest: base,
67
+ needsWrite: true
68
+ };
69
+ if (await io().readText(base).catch(() => null) === content) return {
70
+ dest: base,
71
+ needsWrite: false
72
+ };
73
+ const dir = dirname(base);
74
+ const stem = `${basename(base)}.`;
75
+ try {
76
+ for (const name of await io().list(dir)) {
77
+ if (!name.startsWith(stem)) continue;
78
+ if (!/^\d+$/.test(name.slice(stem.length))) continue;
79
+ const sibling = join(dir, name);
80
+ if (await io().readText(sibling).catch(() => null) === content) return {
81
+ dest: sibling,
82
+ needsWrite: false
83
+ };
84
+ }
85
+ } catch {}
86
+ return {
87
+ dest: `${base}.${Date.now()}`,
88
+ needsWrite: true
89
+ };
67
90
  }
68
91
  async function quarantine(io, root, file, raw, reason) {
69
- const dest = await quarantineTarget(io, `${join(root, file)}.corrupt`, raw);
92
+ const { dest, needsWrite } = await quarantineTarget(io, `${join(root, file)}.corrupt`, raw);
70
93
  let preservedNote = `; original preserved at ${dest} — inspect and fix it, then retry.`;
71
- try {
94
+ if (needsWrite) try {
72
95
  await io().writeText(dest, raw);
73
96
  corruptWritten.delete(file);
74
97
  } catch (writeError) {
75
98
  preservedNote = `; the quarantine copy at ${dest} FAILED to write (${writeError instanceof Error ? writeError.message : String(writeError)}) — the original file is left in place.`;
76
99
  }
100
+ else corruptWritten.delete(file);
77
101
  throw Object.assign(/* @__PURE__ */ new Error(`evolution state file "${file}" is not valid JSON (${reason})${preservedNote}`), { name: QUARANTINE_ERROR_NAME });
78
102
  }
79
103
  /** S-05: the V8-16 record-shape gate existed as two verbatim ~15-line
@@ -140,8 +164,8 @@ async function ensureCorruptCopy(ctx, io, root, file, bad) {
140
164
  })).sort((a, b) => a.id.localeCompare(b.id)));
141
165
  if (corruptWritten.get(file) === corruptKey && await io().exists(base)) return true;
142
166
  const payload = JSON.stringify(bad, null, 2);
143
- const dest = await quarantineTarget(io, base, payload);
144
- const wrote = await io().writeText(dest, payload).then(() => true).catch(() => false);
167
+ const { dest, needsWrite } = await quarantineTarget(io, base, payload);
168
+ const wrote = needsWrite ? await io().writeText(dest, payload).then(() => true).catch(() => false) : true;
145
169
  if (wrote) {
146
170
  corruptWritten.set(file, corruptKey);
147
171
  corruptWriteWarned.delete(file);
@@ -290,24 +314,19 @@ function apply(ctx, rawConfig = {}) {
290
314
  };
291
315
  }
292
316
  /** 0.3.22 (F-336): when the live pending map holds more than
293
- * `PENDING_RESOLVED_CAP` resolved records, drop the oldest (by resolvedAt,
294
- * then insertion order on ties) from the map and return them for archiving.
317
+ * `PENDING_RESOLVED_CAP` resolved records, drop the oldest (by resolvedAt;
318
+ * a missing/unparseable timestamp sorts LAST and leaves first only when the
319
+ * overflow exceeds every known timestamp — v28 G2.4 wording, shared seam
320
+ * rule) from the map and return them for archiving.
295
321
  * Only approved/rejected records are candidates — pending/executing are
296
322
  * live work and are never trimmed. Returns the pruned map (rather than
297
323
  * mutating in place) plus the evicted records. */
298
324
  function enforceResolvedCap(map) {
299
- const resolved = Object.values(map).filter((record) => (record.status === "approved" || record.status === "rejected") && record.kind !== "capability");
300
- if (resolved.length <= PENDING_RESOLVED_CAP$1) return {
325
+ const oldest = selectPendingOverflow(Object.values(map), PENDING_RESOLVED_CAP$1);
326
+ if (oldest.length === 0) return {
301
327
  map,
302
328
  evicted: []
303
329
  };
304
- const overflow = resolved.length - PENDING_RESOLVED_CAP$1;
305
- const entryTime = (record) => {
306
- if (!record.resolvedAt) return Number.MAX_SAFE_INTEGER;
307
- const parsed = Date.parse(record.resolvedAt);
308
- return Number.isNaN(parsed) ? Number.MAX_SAFE_INTEGER : parsed;
309
- };
310
- const oldest = resolved.sort((a, b) => entryTime(a) - entryTime(b)).slice(0, overflow);
311
330
  const oldestKeys = /* @__PURE__ */ new Set();
312
331
  for (const [key, value] of Object.entries(map)) if (oldest.includes(value)) oldestKeys.add(key);
313
332
  const kept = {};
@@ -326,19 +345,34 @@ function apply(ctx, rawConfig = {}) {
326
345
  * read-only legacy `pending.json` re-introduces an evicted record on the
327
346
  * next resolve) and rotate the sidecar to `.bak` past ARCHIVE_RESOLVED_CAP
328
347
  * so neither the file nor the per-append full-array rewrite grows without
329
- * bound. */
348
+ * bound.
349
+ * v28 G0.2 (STATE-01): returns whether the records' resolved evidence
350
+ * LANDED. `false` (rescue-skip or write failure) hands the caller the
351
+ * replay-window responsibility: the evicted records are no longer in the
352
+ * live map and their only durable "resolved" copy failed to land, so the
353
+ * caller must compensate (merge them back) before the legacy merge can
354
+ * revive a ghost twin. A dedupe no-op counts as success — the records are
355
+ * already in the archive. */
330
356
  async function appendArchive(records) {
357
+ let landed = true;
331
358
  try {
332
359
  await transactIo(io(), pathOf(PENDING_ARCHIVE_FILE), async (current) => {
333
360
  let archive = [];
334
- if (current !== null) try {
335
- const parsed = JSON.parse(current);
336
- if (Array.isArray(parsed)) archive = parsed;
337
- } catch {
338
- if (!await io().writeText(`${pathOf(PENDING_ARCHIVE_FILE)}.corrupt`, current).then(() => true, () => false)) {
339
- ctx.logger.warn(`evolution-state-json: corrupt ${PENDING_ARCHIVE_FILE} could not be quarantined to .corrupt — skipping this audit append to preserve the recoverable bytes`);
340
- return current;
361
+ if (current !== null) {
362
+ let parsed;
363
+ let unparseable = false;
364
+ try {
365
+ parsed = JSON.parse(current);
366
+ } catch {
367
+ unparseable = true;
341
368
  }
369
+ if (unparseable || !Array.isArray(parsed)) {
370
+ if (!await io().writeText(`${pathOf(PENDING_ARCHIVE_FILE)}.corrupt`, current).then(() => true, () => false)) {
371
+ ctx.logger.warn(`evolution-state-json: corrupt ${PENDING_ARCHIVE_FILE} could not be quarantined to .corrupt — skipping this audit append to preserve the recoverable bytes`);
372
+ landed = false;
373
+ return current;
374
+ }
375
+ } else archive = parsed;
342
376
  }
343
377
  const shaped = archive.filter((entry) => entry !== null && typeof entry === "object");
344
378
  const archiveKeys = /* @__PURE__ */ new Set();
@@ -355,13 +389,15 @@ function apply(ctx, rawConfig = {}) {
355
389
  if (fresh.length === 0 && !hadDuplicates) return current;
356
390
  const next = [...archive, ...fresh];
357
391
  if (next.length > ARCHIVE_RESOLVED_CAP) {
358
- if (archive.length > 0) await io().writeText(pathOf(PENDING_ARCHIVE_BAK_FILE), JSON.stringify(archive, null, 2)).catch(() => {});
359
- return JSON.stringify((fresh.length > 0 ? fresh : next).slice(-5e3), null, 2);
392
+ if (archive.length > 0) await io().writeText(pathOf(PENDING_ARCHIVE_BAK_FILE), JSON.stringify(archive)).catch(() => {});
393
+ return JSON.stringify((fresh.length > 0 ? fresh : next).slice(-5e3));
360
394
  }
361
- return JSON.stringify(next, null, 2);
395
+ return JSON.stringify(next);
362
396
  });
397
+ return landed;
363
398
  } catch (error) {
364
399
  ctx.logger.warn(`evolution-state-json: archive append deferred: ${error instanceof Error ? error.message : String(error)}`);
400
+ return false;
365
401
  }
366
402
  }
367
403
  const provider = {
@@ -384,9 +420,10 @@ function apply(ctx, rawConfig = {}) {
384
420
  };
385
421
  const others = Object.keys(stamped).filter((id) => id !== sessionId);
386
422
  if (others.length < REVIEW_STATE_SESSION_CAP) return stamped;
387
- const stampOf = (id) => stamped[id]?.updatedAt ?? 0;
388
- others.sort((a, b) => stampOf(a) - stampOf(b));
389
- const evict = new Set(others.slice(0, others.length - REVIEW_STATE_SESSION_CAP + 1));
423
+ const evict = new Set(selectSessionOverflow(others, {
424
+ keyOf: (id) => id,
425
+ stampOf: (id) => stamped[id]?.updatedAt ?? 0
426
+ }));
390
427
  const pruned = {};
391
428
  for (const [id, row] of Object.entries(stamped)) if (!evict.has(id)) pruned[id] = row;
392
429
  return pruned;
@@ -474,7 +511,6 @@ function apply(ctx, rawConfig = {}) {
474
511
  record: null,
475
512
  applied: false
476
513
  };
477
- let evicted = [];
478
514
  await jsonTransact(ctx, io, root, PENDING_STATE_FILE, async (current) => {
479
515
  const map = { ...await mergedWithFilteredLegacy(legacyMigrated ? null : await readJson(PENDING_LEGACY_FILE), current ?? {}) };
480
516
  const record = map[id] ?? null;
@@ -503,10 +539,18 @@ function apply(ctx, rawConfig = {}) {
503
539
  applied: true
504
540
  };
505
541
  const pruned = enforceResolvedCap(map);
506
- evicted = pruned.evicted;
542
+ if (pruned.evicted.length === 0) return pruned.map;
543
+ if (!await appendArchive(pruned.evicted)) {
544
+ const restored = { ...pruned.map };
545
+ for (const record of pruned.evicted) {
546
+ if (record.id in restored) continue;
547
+ restored[record.id] = record;
548
+ }
549
+ ctx.logger.warn(`evolution-state-json: archive append deferred — ${pruned.evicted.length} evicted resolved record(s) were merged back into the live map (retried on the next resolve)`);
550
+ return restored;
551
+ }
507
552
  return pruned.map;
508
553
  });
509
- if (evicted.length > 0) await appendArchive(evicted);
510
554
  return result;
511
555
  });
512
556
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-state-json",
3
3
  "description": "JSON-file evolution state provider over the IO seam (community build)",
4
- "version": "0.3.67",
4
+ "version": "0.3.69",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -31,18 +31,18 @@
31
31
  "license": "MIT",
32
32
  "dependencies": {
33
33
  "@deepseek-ai/schemastery": "^3.18.1",
34
- "@lmzhen/dsh-evolution-core": "^0.3.67"
34
+ "@lmzhen/dsh-evolution-core": "^0.3.69"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
38
38
  "@deepseek-ai/cordis": "^4.0.1",
39
- "@lmzhen/dsh-evolution-io": "^0.3.67",
40
- "@lmzhen/dsh-evolution-state-storage": "^0.3.67"
39
+ "@lmzhen/dsh-evolution-io": "^0.3.69",
40
+ "@lmzhen/dsh-evolution-state-storage": "^0.3.69"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
44
- "@lmzhen/dsh-evolution-io": "^0.3.67",
45
- "@lmzhen/dsh-evolution-state-storage": "^0.3.67",
46
- "@lmzhen/dsh-evolution-io-node": "^0.3.67"
44
+ "@lmzhen/dsh-evolution-io": "^0.3.69",
45
+ "@lmzhen/dsh-evolution-state-storage": "^0.3.69",
46
+ "@lmzhen/dsh-evolution-io-node": "^0.3.69"
47
47
  }
48
48
  }