@lmzhen/dsh-evolution-state-json 0.3.63 → 0.3.65

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/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" && 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);
@@ -185,7 +183,7 @@ async function jsonTransact(ctx, io, root, file, task) {
185
183
  return next === null ? null : JSON.stringify(next, null, 2);
186
184
  });
187
185
  }
188
- function apply(ctx, rawConfig) {
186
+ function apply(ctx, rawConfig = {}) {
189
187
  const root = (rawConfig.root ?? "").trim() || evolutionHome();
190
188
  if (rawConfig.root !== void 0 && rawConfig.root.trim() !== "" && !isAbsolute(rawConfig.root)) ctx.logger.warn(`evolution-state-json: config.root "${rawConfig.root}" is a relative path and resolves against the process CWD ("${root}") — pass an absolute path to make the store location launch-independent`);
191
189
  const io = () => ctx.evolutionIo.provider();
@@ -322,7 +320,9 @@ function apply(ctx, rawConfig) {
322
320
  if (current !== null) try {
323
321
  const parsed = JSON.parse(current);
324
322
  if (Array.isArray(parsed)) archive = parsed;
325
- } catch {}
323
+ } catch {
324
+ await io().writeText(`${pathOf(PENDING_ARCHIVE_FILE)}.corrupt`, current).catch(() => {});
325
+ }
326
326
  const shaped = archive.filter((entry) => entry !== null && typeof entry === "object");
327
327
  const archiveKeys = /* @__PURE__ */ new Set();
328
328
  const collapsed = shaped.filter((entry) => {
@@ -15,5 +15,5 @@ export interface Config {
15
15
  }
16
16
  export declare const Config: z<Config>;
17
17
  export declare function jsonTransact<T>(ctx: Context, io: () => EvolutionIoLike, root: string, file: string, task: (current: T | null) => T | null | Promise<T | null>): Promise<void>;
18
- export declare function apply(ctx: Context, rawConfig: Config): void;
18
+ export declare function apply(ctx: Context, rawConfig?: Config): void;
19
19
  //# sourceMappingURL=index.d.ts.map
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.63",
4
+ "version": "0.3.65",
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.63"
34
+ "@lmzhen/dsh-evolution-core": "^0.3.65"
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.63",
40
- "@lmzhen/dsh-evolution-state-storage": "^0.3.63"
39
+ "@lmzhen/dsh-evolution-io": "^0.3.65",
40
+ "@lmzhen/dsh-evolution-state-storage": "^0.3.65"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
44
- "@lmzhen/dsh-evolution-io": "^0.3.63",
45
- "@lmzhen/dsh-evolution-state-storage": "^0.3.63",
46
- "@lmzhen/dsh-evolution-io-node": "^0.3.63"
44
+ "@lmzhen/dsh-evolution-io": "^0.3.65",
45
+ "@lmzhen/dsh-evolution-state-storage": "^0.3.65",
46
+ "@lmzhen/dsh-evolution-io-node": "^0.3.65"
47
47
  }
48
48
  }