@lmzhen/dsh-evolution-state-json 0.3.64 → 0.3.66

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 +45 -37
  2. package/package.json +7 -7
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
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, PENDING_ARCHIVE_BAK_FILE, PENDING_ARCHIVE_FILE, PENDING_LEGACY_FILE, PENDING_RESOLVED_CAP, PENDING_STATE_FILE, PROVIDER_JSON, REVIEW_STATE_FILE, canClaimPending, canResolvePending, releasedStatus } from "@lmzhen/dsh-evolution-state-storage";
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_TABLE, assertCloneable, canClaimPending, canResolvePending, recordIssue, releasedStatus } from "@lmzhen/dsh-evolution-state-storage";
4
4
  import { isAbsolute, join } from "node:path";
5
5
  //#region lib/types/index.js
6
6
  /**
@@ -56,8 +56,17 @@ const QUARANTINE_ERROR_NAME = "EvolutionStateCorruptFile";
56
56
  * accumulate. The old `.corrupt-<stamp>-<rand>` name minted a fresh file on
57
57
  * EVERY read of a corrupt file: unbounded growth with no sweep. The fixed
58
58
  * copy is swept after 7 days by the node backend's sweepStaleTmps (S-10). */
59
+ /** P2-19 (v19): pick the quarantine destination. The documented fixed name is
60
+ * reused when it already holds the SAME bytes (a repeated read of one corrupt
61
+ * file must still yield exactly one copy — V10-05's bounded-growth rule); a
62
+ * DIFFERENT payload gets a stamped sibling so the earlier rescue copy is never
63
+ * overwritten. The node backend's 7-day `.corrupt` sweep bounds the set. */
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()}`;
67
+ }
59
68
  async function quarantine(io, root, file, raw, reason) {
60
- const dest = `${join(root, file)}.corrupt`;
69
+ const dest = await quarantineTarget(io, `${join(root, file)}.corrupt`, raw);
61
70
  let preservedNote = `; original preserved at ${dest} — inspect and fix it, then retry.`;
62
71
  try {
63
72
  await io().writeText(dest, raw);
@@ -76,27 +85,12 @@ function firstNonRecordValue(parsed) {
76
85
  for (const [recordId, record] of Object.entries(parsed)) if (!isPlainRecord(record)) return `expected a plain object for record "${recordId}", got ${record === null ? "null" : Array.isArray(record) ? "an array" : typeof record}`;
77
86
  return null;
78
87
  }
79
- const isNonNegInt = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
80
- const optionalString = (value) => value === void 0 || typeof value === "string";
81
- const PENDING_KINDS = new Set([
82
- "memory",
83
- "skill",
84
- "capability"
85
- ]);
86
- const PENDING_STATUSES = new Set([
87
- "pending",
88
- "executing",
89
- "approved",
90
- "rejected"
91
- ]);
92
- const gateReviewRecord = (record) => isNonNegInt(record.turnsSinceMemory) && isNonNegInt(record.turnsSinceSkill) && isNonNegInt(record.lastTurn);
93
- const gateCuratorRecord = (record) => typeof record.lastRunAt === "number" && Number.isFinite(record.lastRunAt) && record.lastRunAt >= 0 && isNonNegInt(record.runCount) && typeof record.lastSummary === "string" && typeof record.paused === "boolean";
94
- const gatePendingRecord = (record) => typeof record.id === "string" && typeof record.kind === "string" && PENDING_KINDS.has(record.kind) && typeof record.summary === "string" && "args" in record && typeof record.createdAt === "string" && typeof record.status === "string" && PENDING_STATUSES.has(record.status) && optionalString(record.resolvedAt) && optionalString(record.claimedBy) && optionalString(record.claimedAt) && optionalString(record.origin) && optionalString(record.sessionId);
88
+ const gateFor = (table) => (record) => recordIssue(table, record) === null && assertCloneable(record) === null;
95
89
  const RECORD_FIELD_GATES = {
96
- [REVIEW_STATE_FILE]: gateReviewRecord,
97
- [CURATOR_STATE_FILE]: gateCuratorRecord,
98
- [PENDING_STATE_FILE]: gatePendingRecord,
99
- [PENDING_LEGACY_FILE]: gatePendingRecord
90
+ [REVIEW_STATE_FILE]: gateFor(REVIEW_STATE_TABLE),
91
+ [CURATOR_STATE_FILE]: gateFor(CURATOR_STATE_TABLE),
92
+ [PENDING_STATE_FILE]: gateFor(PENDING_TABLE),
93
+ [PENDING_LEGACY_FILE]: gateFor(PENDING_TABLE)
100
94
  };
101
95
  /** V11-B1 (P2-23): shared per-record field-gate scan for BOTH paths (readJson
102
96
  * + jsonTransact — the transaction baseline used to skip the field gates, so
@@ -139,19 +133,23 @@ function reportGateViolation(ctx, file, failing) {
139
133
  ctx.logger.warn(`evolution-state-json: ${failing.length} record(s) in "${file}" failed the record schema gate and were quarantined to "${file}.corrupt": ${failing.map(([id]) => id).join(", ")}`);
140
134
  }
141
135
  async function ensureCorruptCopy(ctx, io, root, file, bad) {
142
- const corruptPath = `${join(root, file)}.corrupt`;
136
+ const base = `${join(root, file)}.corrupt`;
143
137
  const corruptKey = JSON.stringify(Object.entries(bad).map(([id, record]) => ({
144
138
  id,
145
139
  fields: typeof record === "object" && record !== null ? Object.entries(record).map(([field, value]) => `${field}:${Array.isArray(value) ? "array" : typeof value}`).sort() : [typeof record]
146
140
  })).sort((a, b) => a.id.localeCompare(b.id)));
147
- if (corruptWritten.get(file) === corruptKey && await io().exists(corruptPath)) return;
148
- if (await io().writeText(corruptPath, JSON.stringify(bad, null, 2)).then(() => true).catch(() => false)) {
141
+ if (corruptWritten.get(file) === corruptKey && await io().exists(base)) return true;
142
+ 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);
145
+ if (wrote) {
149
146
  corruptWritten.set(file, corruptKey);
150
147
  corruptWriteWarned.delete(file);
151
148
  } else if (!corruptWriteWarned.has(file)) {
152
149
  corruptWriteWarned.add(file);
153
- ctx.logger.warn(`evolution-state-json: could not write quarantine copy "${corruptPath}" for ${Object.keys(bad).length} failed record(s) — the main file keeps them; the copy is retried on the next access`);
150
+ ctx.logger.warn(`evolution-state-json: could not write quarantine copy "${dest}" for ${Object.keys(bad).length} failed record(s) — the main file keeps them; the copy is retried on the next access`);
154
151
  }
152
+ return wrote;
155
153
  }
156
154
  async function jsonTransact(ctx, io, root, file, task) {
157
155
  await transactIo(io(), join(root, file), async (current) => {
@@ -172,8 +170,8 @@ async function jsonTransact(ctx, io, root, file, task) {
172
170
  for (const [id, record] of Object.entries(parsed)) if (failing.some(([failedId]) => failedId === id)) bad[id] = record;
173
171
  else good[id] = record;
174
172
  reportGateViolation(ctx, file, failing);
175
- await ensureCorruptCopy(ctx, io, root, file, bad);
176
- parsed = good;
173
+ if (await ensureCorruptCopy(ctx, io, root, file, bad)) parsed = good;
174
+ else ctx.logger.warn(`evolution-state-json: keeping ${failing.length} malformed record(s) in ${file} — the quarantine copy could not be written, so rewriting the file without them would destroy the only copy`);
177
175
  }
178
176
  }
179
177
  const next = await task(parsed);
@@ -241,14 +239,21 @@ function apply(ctx, rawConfig = {}) {
241
239
  }
242
240
  return retired;
243
241
  }
244
- async function retireLegacyOnce(legacy, current) {
242
+ async function retireLegacyOnce(legacy) {
245
243
  if (legacyMigrated) return {};
246
244
  try {
247
- const retired = filterLegacy(legacy, current, await readArchivedIds());
248
- await jsonTransact(ctx, io, root, PENDING_STATE_FILE, (fresh) => ({
249
- ...retired,
250
- ...fresh ?? {}
251
- }));
245
+ const retired = {};
246
+ await jsonTransact(ctx, io, root, PENDING_STATE_FILE, async (fresh) => {
247
+ const merged = { ...fresh ?? {} };
248
+ const archivedIds = await readArchivedIds();
249
+ for (const [id, record] of Object.entries(legacy)) {
250
+ if (id in merged) continue;
251
+ if (archivedIds.has(id)) continue;
252
+ merged[id] = record;
253
+ retired[id] = record;
254
+ }
255
+ return merged;
256
+ });
252
257
  await io().rename(pathOf(PENDING_LEGACY_FILE), `${pathOf(PENDING_LEGACY_FILE)}.migrated`);
253
258
  legacyMigrated = true;
254
259
  return retired;
@@ -261,7 +266,7 @@ function apply(ctx, rawConfig = {}) {
261
266
  async function loadPendingMap() {
262
267
  const [current, legacy] = await Promise.all([readJson(PENDING_STATE_FILE), readJson(PENDING_LEGACY_FILE)]);
263
268
  if (legacy !== null) return {
264
- ...await retireLegacyOnce(legacy, current),
269
+ ...await retireLegacyOnce(legacy),
265
270
  ...current ?? {}
266
271
  };
267
272
  return { ...current ?? {} };
@@ -284,7 +289,7 @@ function apply(ctx, rawConfig = {}) {
284
289
  * live work and are never trimmed. Returns the pruned map (rather than
285
290
  * mutating in place) plus the evicted records. */
286
291
  function enforceResolvedCap(map) {
287
- const resolved = Object.values(map).filter((record) => record.status === "approved" || record.status === "rejected");
292
+ const resolved = Object.values(map).filter((record) => (record.status === "approved" || record.status === "rejected") && record.kind !== "capability");
288
293
  if (resolved.length <= PENDING_RESOLVED_CAP$1) return {
289
294
  map,
290
295
  evicted: []
@@ -323,7 +328,10 @@ function apply(ctx, rawConfig = {}) {
323
328
  const parsed = JSON.parse(current);
324
329
  if (Array.isArray(parsed)) archive = parsed;
325
330
  } catch {
326
- await io().writeText(`${pathOf(PENDING_ARCHIVE_FILE)}.corrupt`, current).catch(() => {});
331
+ if (!await io().writeText(`${pathOf(PENDING_ARCHIVE_FILE)}.corrupt`, current).then(() => true, () => false)) {
332
+ 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`);
333
+ return current;
334
+ }
327
335
  }
328
336
  const shaped = archive.filter((entry) => entry !== null && typeof entry === "object");
329
337
  const archiveKeys = /* @__PURE__ */ new Set();
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.64",
4
+ "version": "0.3.66",
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.64"
34
+ "@lmzhen/dsh-evolution-core": "^0.3.66"
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.64",
40
- "@lmzhen/dsh-evolution-state-storage": "^0.3.64"
39
+ "@lmzhen/dsh-evolution-io": "^0.3.66",
40
+ "@lmzhen/dsh-evolution-state-storage": "^0.3.66"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
44
- "@lmzhen/dsh-evolution-io": "^0.3.64",
45
- "@lmzhen/dsh-evolution-state-storage": "^0.3.64",
46
- "@lmzhen/dsh-evolution-io-node": "^0.3.64"
44
+ "@lmzhen/dsh-evolution-io": "^0.3.66",
45
+ "@lmzhen/dsh-evolution-state-storage": "^0.3.66",
46
+ "@lmzhen/dsh-evolution-io-node": "^0.3.66"
47
47
  }
48
48
  }