@lmzhen/dsh-evolution-state-json 0.3.51 → 0.3.53

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/README.md CHANGED
@@ -22,5 +22,5 @@ Independent of request-prefix construction. This package does not alter the asse
22
22
  ## Known Limitations and Deferred Work
23
23
 
24
24
 
25
- - JSON provider serializes writers inside one process AND through the IO backend's cross-process transact lock (`jsonTransact` wraps every mutation, 0.3.20/0.3.27) — this provider is NOT limited to single-process safety. The caveat below is about the DSH storage-domain providers (`storage-json` documents no cross-process write locking) when the DOMAIN provider is used instead; multi-process deployments should route the evolution domain to a backend with cross-process semantics such as SQLite or remote storage.
25
+ - JSON provider serializes writers inside one process AND through the IO backend's cross-process transact lock (an internal transact wrapper — not public API, audit v10 S-03 — wraps every mutation, 0.3.20/0.3.27) — this provider is NOT limited to single-process safety. The caveat below is about the DSH storage-domain providers (`storage-json` documents no cross-process write locking) when the DOMAIN provider is used instead; multi-process deployments should route the evolution domain to a backend with cross-process semantics such as SQLite or remote storage.
26
26
 
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 { canClaimPending, canResolvePending, releasedStatus } from "@lmzhen/dsh-evolution-state-storage";
4
- import { join } from "node:path";
3
+ import { CURATOR_STATE_FILE, CURATOR_STATE_KEY, PENDING_ARCHIVE_BAK_FILE, PENDING_ARCHIVE_FILE, PENDING_LEGACY_FILE, PENDING_STATE_FILE, PROVIDER_JSON, REVIEW_STATE_FILE, canClaimPending, canResolvePending, releasedStatus } from "@lmzhen/dsh-evolution-state-storage";
4
+ import { 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.
@@ -35,22 +35,62 @@ const pendingArchiveKey = (record) => `${record.id}\u0000${record.status}\u0000$
35
35
  * is a top-level ARRAY and must NOT be gated by this predicate. */
36
36
  const isPlainRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
37
37
  const RECORD_MAP_FILES = new Set([
38
- "review-state.json",
39
- "curator-state.json",
40
- "pending-state.json",
41
- "pending.json"
38
+ REVIEW_STATE_FILE,
39
+ CURATOR_STATE_FILE,
40
+ PENDING_STATE_FILE,
41
+ PENDING_LEGACY_FILE
42
42
  ]);
43
+ /** V10-05 (P2-5): discriminant stamped on every quarantine error so
44
+ * best-effort wrappers (retireLegacyOnce) can tell "corrupt state — fail
45
+ * loud" from a transient IO failure and let the quarantine propagate. */
46
+ const QUARANTINE_ERROR_NAME = "EvolutionStateCorruptFile";
43
47
  /** 0.3.17 (E-9): a malformed state file used to parse to `null` and was then
44
48
  * OVERWRITTEN by the next save — every other session's review state / the
45
49
  * whole pending table vanished silently. Fail loud instead: preserve the
46
50
  * original bytes beside it and throw, so the operator can rescue and the
47
- * corruption is never accepted as "empty". */
51
+ * corruption is never accepted as "empty".
52
+ * V10-05 (P2-5): the copy is the FIXED name `<file>.corrupt`, written through
53
+ * the IO seam's atomic write (tmp+rename under the node backend), so it
54
+ * overwrite-commits — at most ONE preserved copy per file can ever
55
+ * accumulate. The old `.corrupt-<stamp>-<rand>` name minted a fresh file on
56
+ * EVERY read of a corrupt file: unbounded growth with no sweep. The fixed
57
+ * copy is swept after 7 days by the node backend's sweepStaleTmps (S-10). */
48
58
  async function quarantine(io, root, file, raw, reason) {
49
- const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
50
- const dest = `${join(root, file)}.corrupt-${stamp}-${Math.random().toString(36).slice(2, 6)}`;
59
+ const dest = `${join(root, file)}.corrupt`;
51
60
  await io().writeText(dest, raw).catch(() => {});
52
- throw new Error(`evolution state file "${file}" is not valid JSON (${reason}); original preserved at ${dest} — inspect and fix it, then retry.`);
61
+ throw Object.assign(/* @__PURE__ */ new Error(`evolution state file "${file}" is not valid JSON (${reason}); original preserved at ${dest} — inspect and fix it, then retry.`), { name: QUARANTINE_ERROR_NAME });
53
62
  }
63
+ /** S-05: the V8-16 record-shape gate existed as two verbatim ~15-line
64
+ * copies (jsonTransact + readJson); this private helper is the single owner.
65
+ * Returns the quarantine reason for the first non-plain-object record value,
66
+ * or null when every value of the map is a plain object. */
67
+ function firstNonRecordValue(parsed) {
68
+ if (!isPlainRecord(parsed)) return null;
69
+ 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}`;
70
+ return null;
71
+ }
72
+ const isNonNegInt = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
73
+ const optionalString = (value) => value === void 0 || typeof value === "string";
74
+ const PENDING_KINDS = new Set([
75
+ "memory",
76
+ "skill",
77
+ "capability"
78
+ ]);
79
+ const PENDING_STATUSES = new Set([
80
+ "pending",
81
+ "executing",
82
+ "approved",
83
+ "rejected"
84
+ ]);
85
+ const gateReviewRecord = (record) => isNonNegInt(record.turnsSinceMemory) && isNonNegInt(record.turnsSinceSkill) && isNonNegInt(record.lastTurn);
86
+ 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";
87
+ 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 RECORD_FIELD_GATES = {
89
+ [REVIEW_STATE_FILE]: gateReviewRecord,
90
+ [CURATOR_STATE_FILE]: gateCuratorRecord,
91
+ [PENDING_STATE_FILE]: gatePendingRecord,
92
+ [PENDING_LEGACY_FILE]: gatePendingRecord
93
+ };
54
94
  /**
55
95
  * Cross-process JSON-file RMW (v3-audit M-8): every read-modify-write state
56
96
  * mutation runs inside the IO backend's transact lock (via transactIo) so a
@@ -61,6 +101,8 @@ async function quarantine(io, root, file, raw, reason) {
61
101
  * array/scalar would be persisted as a corrupt map — so it fails loud before
62
102
  * any write (0.3.28, V4-08). The legacy `pending.json` merge stays inside the
63
103
  * task via `readJson` where relevant.
104
+ * @internal Exported only for this package's own tests — not public API
105
+ * surface (audit v10 S-03); other packages must go through the provider seam.
64
106
  */
65
107
  async function jsonTransact(io, root, file, task) {
66
108
  await transactIo(io(), join(root, file), async (current) => {
@@ -72,9 +114,8 @@ async function jsonTransact(io, root, file, task) {
72
114
  return await quarantine(io, root, file, current, error instanceof Error ? error.message : String(error));
73
115
  }
74
116
  if (RECORD_MAP_FILES.has(file) && !isPlainRecord(parsed)) return await quarantine(io, root, file, current, `expected a plain JSON object (map of records), got ${Array.isArray(parsed) ? "an array" : parsed === null ? "null" : typeof parsed}`);
75
- if (RECORD_MAP_FILES.has(file) && isPlainRecord(parsed)) {
76
- for (const [recordId, record] of Object.entries(parsed)) if (!isPlainRecord(record)) return await quarantine(io, root, file, current, `expected a plain object for record "${recordId}", got ${record === null ? "null" : Array.isArray(record) ? "an array" : typeof record}`);
77
- }
117
+ const malformed = firstNonRecordValue(parsed);
118
+ if (malformed !== null) return await quarantine(io, root, file, current, malformed);
78
119
  }
79
120
  const next = await task(parsed);
80
121
  if (next !== null && RECORD_MAP_FILES.has(file) && !isPlainRecord(next)) throw new Error(`evolution state file "${file}" task returned ${Array.isArray(next) ? "an array" : typeof next} (expected null or a plain JSON object map of records); not written.`);
@@ -83,8 +124,10 @@ async function jsonTransact(io, root, file, task) {
83
124
  }
84
125
  function apply(ctx, rawConfig) {
85
126
  const root = (rawConfig.root ?? "").trim() || evolutionHome();
127
+ 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`);
86
128
  const io = () => ctx.evolutionIo.provider();
87
129
  const pathOf = (file) => join(root, file);
130
+ const recordGateWarned = /* @__PURE__ */ new Set();
88
131
  async function readJson(file) {
89
132
  const raw = await io().readText(pathOf(file));
90
133
  if (raw === null) return null;
@@ -95,8 +138,24 @@ function apply(ctx, rawConfig) {
95
138
  return await quarantine(io, root, file, raw, error instanceof Error ? error.message : String(error));
96
139
  }
97
140
  if (RECORD_MAP_FILES.has(file) && !isPlainRecord(parsed)) return await quarantine(io, root, file, raw, `expected a plain JSON object (map of records), got ${Array.isArray(parsed) ? "an array" : parsed === null ? "null" : typeof parsed}`);
98
- if (RECORD_MAP_FILES.has(file) && isPlainRecord(parsed)) {
99
- for (const [recordId, record] of Object.entries(parsed)) if (!isPlainRecord(record)) return await quarantine(io, root, file, raw, `expected a plain object for record "${recordId}", got ${record === null ? "null" : Array.isArray(record) ? "an array" : typeof record}`);
141
+ const malformed = firstNonRecordValue(parsed);
142
+ if (malformed !== null) return await quarantine(io, root, file, raw, malformed);
143
+ const gate = RECORD_FIELD_GATES[file];
144
+ if (gate !== void 0 && isPlainRecord(parsed)) {
145
+ const entries = Object.entries(parsed);
146
+ const failing = entries.filter(([, record]) => !isPlainRecord(record) || !gate(record)).map(([id]) => id);
147
+ if (failing.length > 0) {
148
+ const bad = {};
149
+ const good = {};
150
+ for (const [id, record] of entries) if (failing.includes(id)) bad[id] = record;
151
+ else good[id] = record;
152
+ await io().writeText(`${pathOf(file)}.corrupt`, JSON.stringify(bad, null, 2)).catch(() => {});
153
+ if (!recordGateWarned.has(file)) {
154
+ recordGateWarned.add(file);
155
+ ctx.logger.warn(`evolution-state-json: ${failing.length} record(s) in "${file}" failed the record schema gate and were quarantined to "${file}.corrupt": ${failing.join(", ")}`);
156
+ }
157
+ return good;
158
+ }
100
159
  }
101
160
  return parsed;
102
161
  }
@@ -107,12 +166,12 @@ function apply(ctx, rawConfig) {
107
166
  if (archivedIdsCache !== null) return archivedIdsCache;
108
167
  const ids = /* @__PURE__ */ new Set();
109
168
  try {
110
- const rawArchive = await readJson("pending-state-archive.json");
169
+ const rawArchive = await readJson(PENDING_ARCHIVE_FILE);
111
170
  if (Array.isArray(rawArchive)) {
112
171
  for (const entry of rawArchive) if (entry && typeof entry.id === "string") ids.add(entry.id);
113
172
  }
114
173
  try {
115
- const rawBak = await readJson("pending-state-archive.json.bak");
174
+ const rawBak = await readJson(PENDING_ARCHIVE_BAK_FILE);
116
175
  if (Array.isArray(rawBak)) {
117
176
  for (const entry of rawBak) if (entry && typeof entry.id === "string") ids.add(entry.id);
118
177
  }
@@ -134,19 +193,21 @@ function apply(ctx, rawConfig) {
134
193
  if (legacyMigrated) return {};
135
194
  try {
136
195
  const retired = filterLegacy(legacy, current, await readArchivedIds());
137
- await jsonTransact(io, root, "pending-state.json", (fresh) => ({
196
+ await jsonTransact(io, root, PENDING_STATE_FILE, (fresh) => ({
138
197
  ...retired,
139
198
  ...fresh ?? {}
140
199
  }));
141
- await io().rename(pathOf("pending.json"), pathOf("pending.json.migrated"));
200
+ await io().rename(pathOf(PENDING_LEGACY_FILE), `${pathOf(PENDING_LEGACY_FILE)}.migrated`);
142
201
  legacyMigrated = true;
143
202
  return retired;
144
- } catch {
203
+ } catch (error) {
204
+ if (error instanceof Error && error.name === QUARANTINE_ERROR_NAME) throw error;
205
+ ctx.logger.warn(`evolution-state-json: legacy pending retirement deferred: ${error instanceof Error ? error.message : String(error)}`);
145
206
  return legacy;
146
207
  }
147
208
  }
148
209
  async function loadPendingMap() {
149
- const [current, legacy] = await Promise.all([readJson("pending-state.json"), readJson("pending.json")]);
210
+ const [current, legacy] = await Promise.all([readJson(PENDING_STATE_FILE), readJson(PENDING_LEGACY_FILE)]);
150
211
  if (legacy !== null) return {
151
212
  ...await retireLegacyOnce(legacy, current),
152
213
  ...current ?? {}
@@ -204,7 +265,7 @@ function apply(ctx, rawConfig) {
204
265
  * bound. */
205
266
  async function appendArchive(records) {
206
267
  try {
207
- await transactIo(io(), pathOf("pending-state-archive.json"), async (current) => {
268
+ await transactIo(io(), pathOf(PENDING_ARCHIVE_FILE), async (current) => {
208
269
  let archive = [];
209
270
  if (current !== null) try {
210
271
  const parsed = JSON.parse(current);
@@ -224,7 +285,7 @@ function apply(ctx, rawConfig) {
224
285
  if (fresh.length === 0 && !hadDuplicates) return current;
225
286
  const next = [...archive, ...fresh];
226
287
  if (next.length > ARCHIVE_RESOLVED_CAP) {
227
- if (archive.length > 0) await io().writeText(pathOf("pending-state-archive.json.bak"), JSON.stringify(archive, null, 2)).catch(() => {});
288
+ if (archive.length > 0) await io().writeText(pathOf(PENDING_ARCHIVE_BAK_FILE), JSON.stringify(archive, null, 2)).catch(() => {});
228
289
  archivedIdsCache = null;
229
290
  return JSON.stringify((fresh.length > 0 ? fresh : next).slice(-5e3), null, 2);
230
291
  }
@@ -234,15 +295,15 @@ function apply(ctx, rawConfig) {
234
295
  } catch {}
235
296
  }
236
297
  const provider = {
237
- name: "json",
298
+ name: PROVIDER_JSON,
238
299
  async loadReviewState(sessionId) {
239
300
  return await mutate(async () => {
240
- return (await readJson("review-state.json"))?.[sessionId] ?? null;
301
+ return (await readJson(REVIEW_STATE_FILE))?.[sessionId] ?? null;
241
302
  });
242
303
  },
243
304
  async saveReviewState(sessionId, record) {
244
305
  await mutate(async () => {
245
- await jsonTransact(io, root, "review-state.json", (current) => ({
306
+ await jsonTransact(io, root, REVIEW_STATE_FILE, (current) => ({
246
307
  ...current ?? {},
247
308
  [sessionId]: record
248
309
  }));
@@ -250,25 +311,25 @@ function apply(ctx, rawConfig) {
250
311
  },
251
312
  async loadCuratorState() {
252
313
  return await mutate(async () => {
253
- return (await readJson("curator-state.json"))?.primary ?? null;
314
+ return (await readJson(CURATOR_STATE_FILE))?.[CURATOR_STATE_KEY] ?? null;
254
315
  });
255
316
  },
256
317
  async saveCuratorState(record) {
257
318
  await mutate(async () => {
258
- await jsonTransact(io, root, "curator-state.json", (current) => ({
319
+ await jsonTransact(io, root, CURATOR_STATE_FILE, (current) => ({
259
320
  ...current ?? {},
260
- primary: record
321
+ [CURATOR_STATE_KEY]: record
261
322
  }));
262
323
  });
263
324
  },
264
325
  async transactCuratorState(task) {
265
326
  await mutate(async () => {
266
- await jsonTransact(io, root, "curator-state.json", (current) => {
267
- const next = task(current?.primary ?? null);
327
+ await jsonTransact(io, root, CURATOR_STATE_FILE, (current) => {
328
+ const next = task(current?.[CURATOR_STATE_KEY] ?? null);
268
329
  if (next === null) return current;
269
330
  return {
270
331
  ...current ?? {},
271
- primary: next
332
+ [CURATOR_STATE_KEY]: next
272
333
  };
273
334
  });
274
335
  });
@@ -281,9 +342,9 @@ function apply(ctx, rawConfig) {
281
342
  },
282
343
  async savePending(record) {
283
344
  await mutate(async () => {
284
- await jsonTransact(io, root, "pending-state.json", async (current) => {
345
+ await jsonTransact(io, root, PENDING_STATE_FILE, async (current) => {
285
346
  return {
286
- ...await mergedWithFilteredLegacy(legacyMigrated ? null : await readJson("pending.json"), current ?? {}),
347
+ ...await mergedWithFilteredLegacy(legacyMigrated ? null : await readJson(PENDING_LEGACY_FILE), current ?? {}),
287
348
  [record.id]: record
288
349
  };
289
350
  });
@@ -292,8 +353,8 @@ function apply(ctx, rawConfig) {
292
353
  async claimPending(id, claimId) {
293
354
  return await mutate(async () => {
294
355
  const slot = { claimed: null };
295
- await jsonTransact(io, root, "pending-state.json", async (current) => {
296
- const map = { ...await mergedWithFilteredLegacy(legacyMigrated ? null : await readJson("pending.json"), current ?? {}) };
356
+ await jsonTransact(io, root, PENDING_STATE_FILE, async (current) => {
357
+ const map = { ...await mergedWithFilteredLegacy(legacyMigrated ? null : await readJson(PENDING_LEGACY_FILE), current ?? {}) };
297
358
  const record = map[id] ?? null;
298
359
  if (record === null || !canClaimPending(record.status)) return map;
299
360
  const now = Date.now();
@@ -311,8 +372,8 @@ function apply(ctx, rawConfig) {
311
372
  },
312
373
  async releasePendingClaim(id, claimId) {
313
374
  await mutate(async () => {
314
- await jsonTransact(io, root, "pending-state.json", async (current) => {
315
- const map = { ...await mergedWithFilteredLegacy(legacyMigrated ? null : await readJson("pending.json"), current ?? {}) };
375
+ await jsonTransact(io, root, PENDING_STATE_FILE, async (current) => {
376
+ const map = { ...await mergedWithFilteredLegacy(legacyMigrated ? null : await readJson(PENDING_LEGACY_FILE), current ?? {}) };
316
377
  const record = map[id];
317
378
  if (!record || record.claimedBy !== claimId) return map;
318
379
  if (record.status !== "pending" && record.status !== "executing") return map;
@@ -330,8 +391,8 @@ function apply(ctx, rawConfig) {
330
391
  applied: false
331
392
  };
332
393
  let evicted = [];
333
- await jsonTransact(io, root, "pending-state.json", async (current) => {
334
- const map = { ...await mergedWithFilteredLegacy(legacyMigrated ? null : await readJson("pending.json"), current ?? {}) };
394
+ await jsonTransact(io, root, PENDING_STATE_FILE, async (current) => {
395
+ const map = { ...await mergedWithFilteredLegacy(legacyMigrated ? null : await readJson(PENDING_LEGACY_FILE), current ?? {}) };
335
396
  const record = map[id] ?? null;
336
397
  if (record === null || !canResolvePending(record.status)) {
337
398
  result = {
@@ -24,6 +24,8 @@ export declare const Config: z<Config>;
24
24
  * array/scalar would be persisted as a corrupt map — so it fails loud before
25
25
  * any write (0.3.28, V4-08). The legacy `pending.json` merge stays inside the
26
26
  * task via `readJson` where relevant.
27
+ * @internal Exported only for this package's own tests — not public API
28
+ * surface (audit v10 S-03); other packages must go through the provider seam.
27
29
  */
28
30
  export declare function jsonTransact<T>(io: () => EvolutionIoLike, root: string, file: string, task: (current: T | null) => T | null | Promise<T | null>): Promise<void>;
29
31
  export declare function apply(ctx: Context, rawConfig: Config): void;
package/package.json CHANGED
@@ -1,14 +1,14 @@
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.51",
4
+ "version": "0.3.53",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/lmzhen/dsh-evolution.git",
11
- "directory": "packages/dsh-evolution-state-json"
11
+ "directory": "packages/evolution-state-json"
12
12
  },
13
13
  "type": "module",
14
14
  "main": "lib/index.js",
@@ -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.51"
34
+ "@lmzhen/dsh-evolution-core": "^0.3.53"
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.51",
40
- "@lmzhen/dsh-evolution-state-storage": "^0.3.51"
39
+ "@lmzhen/dsh-evolution-io": "^0.3.53",
40
+ "@lmzhen/dsh-evolution-state-storage": "^0.3.53"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
44
- "@lmzhen/dsh-evolution-io": "^0.3.51",
45
- "@lmzhen/dsh-evolution-state-storage": "^0.3.51",
46
- "@lmzhen/dsh-evolution-io-node": "^0.3.51"
44
+ "@lmzhen/dsh-evolution-io": "^0.3.53",
45
+ "@lmzhen/dsh-evolution-state-storage": "^0.3.53",
46
+ "@lmzhen/dsh-evolution-io-node": "^0.3.53"
47
47
  }
48
48
  }