@lmzhen/dsh-evolution-core 0.26.26 → 0.27.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/README.md +16 -1
- package/lib/composition/index.js +3 -0
- package/lib/composition-D8Y3-ROd.js +604 -0
- package/lib/ensure-MEYMj9jn.js +222 -0
- package/lib/errors-Br2-uUSK.js +57 -0
- package/lib/http-routes-BP-S3KV-.js +149 -0
- package/lib/http-routes.js +2 -0
- package/lib/index.js +66 -850
- package/lib/restrictor.js +37 -0
- package/lib/subagent/index.js +186 -0
- package/lib/types/citations.js +192 -0
- package/lib/types/{preset-composition.d.ts → composition/composer.d.ts} +1 -1
- package/lib/types/composition/composer.js +681 -0
- package/lib/types/composition/ensure.d.ts +10 -0
- package/lib/types/composition/ensure.js +95 -0
- package/lib/types/composition/index.d.ts +5 -0
- package/lib/types/composition/index.js +7 -0
- package/lib/types/composition/preset-ids.d.ts +17 -0
- package/lib/types/composition/preset-ids.js +21 -0
- package/lib/types/composition/restricted-preset.d.ts +36 -0
- package/lib/types/composition/restricted-preset.js +42 -0
- package/lib/types/constants.js +223 -0
- package/lib/types/cost.js +67 -0
- package/lib/types/curator.js +346 -0
- package/lib/types/diagnostics.d.ts +26 -0
- package/lib/types/diagnostics.js +23 -0
- package/lib/types/document-body.js +91 -0
- package/lib/types/drift-signals.js +319 -0
- package/lib/types/env.js +25 -0
- package/lib/types/errors.d.ts +4 -1
- package/lib/types/errors.js +59 -0
- package/lib/types/events.js +16 -0
- package/lib/types/evidence.js +65 -0
- package/lib/types/evolution-events.js +479 -0
- package/lib/types/fold-untrusted.js +44 -0
- package/lib/types/frontmatter.js +623 -0
- package/lib/types/fuzzy-match.js +135 -0
- package/lib/types/gates.js +51 -0
- package/lib/types/guards.js +27 -0
- package/lib/types/health-baseline.js +63 -0
- package/lib/types/http-routes.d.ts +67 -35
- package/lib/types/http-routes.js +135 -0
- package/lib/types/index.d.ts +7 -2
- package/lib/types/index.js +101 -0
- package/lib/types/instance-scope.js +69 -0
- package/lib/types/io.js +675 -0
- package/lib/types/learn-prompt.js +42 -0
- package/lib/types/limits.js +63 -0
- package/lib/types/live.js +107 -0
- package/lib/types/long-runs.js +96 -0
- package/lib/types/memory-store.d.ts +0 -16
- package/lib/types/memory-store.js +606 -0
- package/lib/types/mutations.js +95 -0
- package/lib/types/namespace-migration.js +156 -0
- package/lib/types/numeric.js +47 -0
- package/lib/types/opt-in.js +101 -0
- package/lib/types/package-identity.js +47 -0
- package/lib/types/params.js +328 -0
- package/lib/types/payload.js +36 -0
- package/lib/types/platform-line.generated.d.ts +10 -0
- package/lib/types/platform-line.generated.js +12 -0
- package/lib/types/platform-services.d.ts +2 -2
- package/lib/types/platform-services.js +52 -0
- package/lib/types/probe.js +122 -0
- package/lib/types/prompts.js +409 -0
- package/lib/types/quality.js +308 -0
- package/lib/types/reachability.js +122 -0
- package/lib/types/redact.js +257 -0
- package/lib/types/reference-rewrite.js +144 -0
- package/lib/types/reports.js +110 -0
- package/lib/types/restrictor.d.ts +11 -0
- package/lib/types/restrictor.js +48 -0
- package/lib/types/review-channel.js +59 -0
- package/lib/types/run-registry.js +345 -0
- package/lib/types/scope.js +7 -0
- package/lib/types/serial.js +17 -0
- package/lib/types/session-fold.d.ts +15 -0
- package/lib/types/session-fold.js +28 -0
- package/lib/types/session-projection.js +295 -0
- package/lib/types/signals.js +209 -0
- package/lib/types/skill-discovery.js +66 -0
- package/lib/types/skill-health.js +134 -0
- package/lib/types/skill-history.js +745 -0
- package/lib/types/skill-lint.js +112 -0
- package/lib/types/skill-reads.js +67 -0
- package/lib/types/skill-row-cells.js +123 -0
- package/lib/types/skill-store.js +3528 -0
- package/lib/types/state-store.js +60 -0
- package/lib/types/store-ledger.js +117 -0
- package/lib/types/subagent/channel.d.ts +4 -0
- package/lib/types/subagent/channel.js +131 -0
- package/lib/types/subagent/index.d.ts +4 -0
- package/lib/types/subagent/index.js +5 -0
- package/lib/types/subagent/readiness.d.ts +6 -0
- package/lib/types/subagent/readiness.js +35 -0
- package/lib/types/subagent/types.d.ts +58 -0
- package/lib/types/subagent/types.js +2 -0
- package/lib/types/threats.js +338 -0
- package/lib/types/tool-dispatch.d.ts +5 -0
- package/lib/types/tool-dispatch.js +535 -0
- package/lib/types/tree-write.js +40 -0
- package/lib/types/unit-members.js +121 -0
- package/lib/types/usage.js +474 -0
- package/lib/types/write-authority.js +23 -0
- package/lib/types/write-gates.js +344 -0
- package/lib/types/write-inventory.js +158 -0
- package/package.json +39 -11
package/lib/types/io.js
ADDED
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural IO seam for the evolution plugin family.
|
|
3
|
+
*
|
|
4
|
+
* Every evolution package passes `ctx.evolutionIo.provider()`; standalone
|
|
5
|
+
* consumers (and the core's own tests) can use `nodeEvolutionIo`.
|
|
6
|
+
*/
|
|
7
|
+
import { copyFileSync, existsSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { cp, lstat, mkdir, open, readFile, readdir, rename, rm, stat } from 'node:fs/promises';
|
|
9
|
+
import { basename, dirname, join } from 'node:path';
|
|
10
|
+
import { randomBytes } from 'node:crypto';
|
|
11
|
+
// v43 S1-4: the family's ONE definition of "this read failure means it is not
|
|
12
|
+
// there" lives in probe.ts; this backend delegates instead of keeping a second
|
|
13
|
+
// copy of the same two codes.
|
|
14
|
+
import { isMissingPath } from "./probe.js";
|
|
15
|
+
/**
|
|
16
|
+
* Run `task` inside `io.transact` when the backend provides it; otherwise fall
|
|
17
|
+
* back to a plain read → task → write/remove sequence (no cross-process lock —
|
|
18
|
+
* callers keep their single-process serialize chain as the second layer). A
|
|
19
|
+
* byte-identical task result skips the write (C-07 — parity with the
|
|
20
|
+
* node backend's V5-03 short-circuit).
|
|
21
|
+
*/
|
|
22
|
+
export async function transactIo(io, path, task) {
|
|
23
|
+
// V27 G1.3: a dir-fsync failure AFTER the rename (`committed: true`) means the
|
|
24
|
+
// bytes ARE on disk. Every durable consumer in this family reaches its write
|
|
25
|
+
// through this helper, so the tolerance lives here instead of in seven
|
|
26
|
+
// packages: propagating it made memory/usage/mutations/state/activity/
|
|
27
|
+
// feedback/events report "not written" for a write that happened, and a
|
|
28
|
+
// two-phase caller could then roll back a visible write. The durability loss
|
|
29
|
+
// is still observable — it is logged, not swallowed.
|
|
30
|
+
const committedOnly = (error) => isCommittedWarning(error);
|
|
31
|
+
if (io.transact) {
|
|
32
|
+
try {
|
|
33
|
+
await io.transact(path, task);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (!committedOnly(error))
|
|
38
|
+
throw error;
|
|
39
|
+
console.warn(`evolution-io: ${path} was written but its directory fsync failed — the bytes are visible, durability is unconfirmed: ${error instanceof Error ? error.message : String(error)}`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const current = await io.readText(path);
|
|
44
|
+
const next = await task(current);
|
|
45
|
+
if (next === null)
|
|
46
|
+
await io.remove(path);
|
|
47
|
+
// C-07: a byte-identical result is a no-op — the same V5-03
|
|
48
|
+
// short-circuit the node backend's transact already has. Without it every
|
|
49
|
+
// fallback RMW churned a tmp+rename and touched the mtime even when nothing
|
|
50
|
+
// changed, so transact-less backends behaved asymmetrically.
|
|
51
|
+
else if (next !== current) {
|
|
52
|
+
try {
|
|
53
|
+
await io.writeText(path, next);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (!committedOnly(error))
|
|
57
|
+
throw error;
|
|
58
|
+
console.warn(`evolution-io: ${path} was written but its directory fsync failed — the bytes are visible, durability is unconfirmed: ${error instanceof Error ? error.message : String(error)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Build a {@link TransactTaskGuard} for one write path. See its doc. */
|
|
63
|
+
export function transactTaskGuard(what) {
|
|
64
|
+
let invoked = false;
|
|
65
|
+
return {
|
|
66
|
+
wrap: (task) => (current) => {
|
|
67
|
+
invoked = true;
|
|
68
|
+
return task(current);
|
|
69
|
+
},
|
|
70
|
+
invoked: () => invoked,
|
|
71
|
+
assertInvoked: () => {
|
|
72
|
+
if (invoked)
|
|
73
|
+
return;
|
|
74
|
+
throw new Error(`internal error: the write transaction for ${what} did not invoke the task; no write was performed`);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Lazy adapter over an IO provider registry, shared by every evolution consumer. */
|
|
79
|
+
export function evolutionIoAdapter(provider) {
|
|
80
|
+
return {
|
|
81
|
+
readText: path => provider().readText(path),
|
|
82
|
+
writeText: (path, content) => provider().writeText(path, content),
|
|
83
|
+
remove: path => provider().remove(path),
|
|
84
|
+
list: path => provider().list(path),
|
|
85
|
+
exists: path => provider().exists(path),
|
|
86
|
+
rename: (path, destination) => provider().rename(path, destination),
|
|
87
|
+
copy: (path, destination) => provider().copy(path, destination),
|
|
88
|
+
// Never throws: a backend without a size probe means "guard not applicable".
|
|
89
|
+
size: (path) => {
|
|
90
|
+
const io = provider();
|
|
91
|
+
return io.size ? io.size(path) : Promise.resolve(null);
|
|
92
|
+
},
|
|
93
|
+
// Transact and symlink probes are forwarding-only: the fallback semantics
|
|
94
|
+
// live in the consumer helper / the backend itself.
|
|
95
|
+
transact: (path, task) => {
|
|
96
|
+
const io = provider();
|
|
97
|
+
return io.transact ? io.transact(path, task) : transactIo(io, path, task);
|
|
98
|
+
},
|
|
99
|
+
isSymlink: (path) => {
|
|
100
|
+
const io = provider();
|
|
101
|
+
return io.isSymlink ? io.isSymlink(path) : Promise.resolve(null);
|
|
102
|
+
},
|
|
103
|
+
// Never throws: a backend without the probe reports null (cache keeps its
|
|
104
|
+
// event-driven invalidation only — 0.3.18, E-71).
|
|
105
|
+
mtime: (path) => {
|
|
106
|
+
const io = provider();
|
|
107
|
+
return io.mtime ? io.mtime(path) : Promise.resolve(null);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// C-28: the transient-EPERM/EBUSY retry budget grows from 3x50ms
|
|
112
|
+
// (~150ms — shorter than a real antivirus/indexer scan window) to ~2s, the
|
|
113
|
+
// same magnitude as the write-lock retry budget (rc.69), with exponential
|
|
114
|
+
// backoff so a short hold recovers fast and a long hold still recovers.
|
|
115
|
+
const RENAME_RETRY_BASE_MS = 50;
|
|
116
|
+
const RENAME_RETRY_MAX_DELAY_MS = 800;
|
|
117
|
+
// 50 + 100 + 200 + 400 + 800 + 800 = 2350ms of backoff (~2s budget).
|
|
118
|
+
const RENAME_RETRY_MAX_ATTEMPTS = 6;
|
|
119
|
+
/**
|
|
120
|
+
* Retry a rename that a peer is temporarily holding on Windows (EPERM/EBUSY):
|
|
121
|
+
* exponential backoff (50ms doubling, capped at 800ms) with a ~2s total
|
|
122
|
+
* budget, matching the write-lock cadence (C-28 — the old 3x50ms
|
|
123
|
+
* budget turned a transient antivirus hold into a permanent write failure).
|
|
124
|
+
* A non-transient code surfaces immediately; a persistent EPERM/EBUSY
|
|
125
|
+
* rethrows with a pointer at the usual causes instead of a bare errno.
|
|
126
|
+
* `fn` is the rename primitive, injectable for deterministic tests.
|
|
127
|
+
*
|
|
128
|
+
* v22 (LOCK-5) documented platform limit: POSIX rename-over-existing is
|
|
129
|
+
* atomic for concurrent READERS, but Windows MoveFileExW(REPLACE_EXISTING)
|
|
130
|
+
* can make the target briefly invisible (ENOENT) while the replacement is in
|
|
131
|
+
* flight. The retry budget above covers the WRITE side only — a LOCKLESS
|
|
132
|
+
* reader (`readText`/`readJson` on a path it does not hold the lock for) can
|
|
133
|
+
* observe that window and see "missing" for a file that was just committed.
|
|
134
|
+
* Verified harmless today: every lockless-read consumer treats the transient
|
|
135
|
+
* miss as self-healing state (no consumer persists a read of null into a
|
|
136
|
+
* write), and distinguishing that ENOENT from a genuinely absent file in
|
|
137
|
+
* `readText` would tax every ordinary missing-file probe. Revisit only if a
|
|
138
|
+
* consumer appears that must never transiently miss.
|
|
139
|
+
*
|
|
140
|
+
* @param tmp - the source path to rename.
|
|
141
|
+
* @param target - the destination path.
|
|
142
|
+
* @param fn - the rename primitive (defaults to `node:fs/promises.rename`).
|
|
143
|
+
* @returns a promise that resolves once the rename succeeds.
|
|
144
|
+
*/
|
|
145
|
+
export async function renameWithRetry(tmp, target, fn = rename) {
|
|
146
|
+
for (let retry = 0;; retry += 1) {
|
|
147
|
+
try {
|
|
148
|
+
await fn(tmp, target);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
const code = error?.code;
|
|
153
|
+
if (code !== 'EPERM' && code !== 'EBUSY')
|
|
154
|
+
throw error;
|
|
155
|
+
if (retry >= RENAME_RETRY_MAX_ATTEMPTS) {
|
|
156
|
+
// C-28: keep the original message (callers match on it) and
|
|
157
|
+
// append the direction a bare EPERM never gave: the target — not the
|
|
158
|
+
// tmp — is the thing being held or refused.
|
|
159
|
+
const final = error;
|
|
160
|
+
final.message = `${final.message} (persisted after ${retry + 1} rename attempts over ~2s — the target may be held by another process or marked read-only; check antivirus, search indexers and file attributes)`;
|
|
161
|
+
throw final;
|
|
162
|
+
}
|
|
163
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(RENAME_RETRY_BASE_MS * 2 ** retry, RENAME_RETRY_MAX_DELAY_MS)));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* V10-06 (P1-1): the crash-durable tmp half of the upstream storage-json
|
|
169
|
+
* `writeAtomic` protocol (storage-json/src/atomic.ts:24-40): exclusive-create
|
|
170
|
+
* a same-directory tmp (`wx` — never clobbers), write, `handle.sync()`,
|
|
171
|
+
* close, then hand the tmp to `commitTmp` for the rename. Without the fsync a
|
|
172
|
+
* power loss could land the rename (metadata) before the data blocks and
|
|
173
|
+
* leave an empty/truncated target — which the state-json E-9 quarantine then
|
|
174
|
+
* amplifies into a permanent fail-loud. The tmp name keeps the
|
|
175
|
+
* `<target>.<pid>.<rand>.tmp` shape so sweepStaleTmps keeps matching.
|
|
176
|
+
* Mode parity note: no explicit mode is passed (upstream uses 0o600) — this
|
|
177
|
+
* seam also writes operator-editable skill/memory files, so the previous
|
|
178
|
+
* `writeFile` default (0o666 & ~umask) is deliberately preserved.
|
|
179
|
+
* `openImpl` is injectable so the sync-failure regression test can drive a
|
|
180
|
+
* failing `handle.sync()` deterministically.
|
|
181
|
+
*/
|
|
182
|
+
export async function writeDurableTmp(target, content, openImpl = open) {
|
|
183
|
+
const tmp = `${target}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
184
|
+
try {
|
|
185
|
+
// P3-9 (v14): commitTmp renames the tmp ONTO the target, so the target
|
|
186
|
+
// inherits the tmp's inode and mode. Without carrying the target's own
|
|
187
|
+
// mode, a file an operator tightened to 0o600 silently widened to
|
|
188
|
+
// 0o666 & ~umask on the next write. POSIX only — Windows ignores mode bits.
|
|
189
|
+
const mode = process.platform === 'win32'
|
|
190
|
+
? undefined
|
|
191
|
+
: await stat(target).then(value => value.mode & 0o777).catch(() => undefined);
|
|
192
|
+
const handle = await openImpl(tmp, 'wx', mode);
|
|
193
|
+
try {
|
|
194
|
+
await handle.writeFile(content, 'utf8');
|
|
195
|
+
await handle.sync();
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
await handle.close();
|
|
199
|
+
}
|
|
200
|
+
return tmp;
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
// Never leak the tmp of a failed durable write (same tail as upstream).
|
|
204
|
+
await rm(tmp, { force: true }).catch(() => { });
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** V10-06 (P1-1): fsync a POSIX directory so a just-renamed entry is
|
|
209
|
+
* crash-durable (upstream atomic.ts `fsyncDirectory`). */
|
|
210
|
+
/* v8 ignore start -- Windows rejects O_RDONLY directory opens; POSIX coverage exercises this. */
|
|
211
|
+
async function fsyncDirectory(path) {
|
|
212
|
+
if (process.platform === 'win32')
|
|
213
|
+
return;
|
|
214
|
+
const handle = await open(path, 'r');
|
|
215
|
+
try {
|
|
216
|
+
await handle.sync();
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
await handle.close();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/* v8 ignore stop */
|
|
223
|
+
/**
|
|
224
|
+
* F-366: commit a freshly-written tmp to its target inside the write lock. On a
|
|
225
|
+
* still-failing rename the tmp is deleted immediately rather than left for the
|
|
226
|
+
* (1h + dead-pid) sweep, so a live writer never leaks a tmp it abandoned.
|
|
227
|
+
* V10-06 (P1-1): after a successful rename the parent directory is fsynced on
|
|
228
|
+
* POSIX so the new directory entry itself is crash-durable (upstream atomic.ts
|
|
229
|
+
* tail; Windows skips — it rejects O_RDONLY directory opens). A dir-fsync
|
|
230
|
+
* failure propagates like upstream: the data is on disk, but the durability
|
|
231
|
+
* contract failed loud.
|
|
232
|
+
*/
|
|
233
|
+
async function commitTmp(tmp, target) {
|
|
234
|
+
try {
|
|
235
|
+
await renameWithRetry(tmp, target);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
await rm(tmp, { force: true }).catch(() => { });
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
await fsyncDirectory(dirname(target));
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
// A1-15 (v18): the rename already landed, so the bytes ARE on disk and the
|
|
246
|
+
// tmp is gone. Mark the failure so the write path can still audit/notify
|
|
247
|
+
// and report "written, durability unconfirmed" instead of a plain failure
|
|
248
|
+
// that a two-phase caller would try to roll back (deleting a visible write).
|
|
249
|
+
throw Object.assign(new Error(`commitTmp: ${target} was renamed but the directory fsync failed: ${error instanceof Error ? error.message : String(error)}`), { committed: true, cause: error });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* True when the pid is alive (EPERM = alive but unowned; ESRCH = gone).
|
|
254
|
+
* V18 single source: the node backend's lock takeover and SkillLibrary's
|
|
255
|
+
* stranded-lock sweep must use the same liveness rule.
|
|
256
|
+
*/
|
|
257
|
+
export function isProcessAlive(pid) {
|
|
258
|
+
try {
|
|
259
|
+
process.kill(pid, 0);
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
return error?.code === 'EPERM';
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** How long an abandoned quarantine copy is kept for operator rescue. G2 S4: hoisted to module scope
|
|
267
|
+
* (it used to live inside `nodeEvolutionIo`, in the region the lock protocol occupied). */
|
|
268
|
+
const CORRUPT_SWEEP_AGE_MS = 7 * 24 * 3_600_000;
|
|
269
|
+
/** V27 G1.4: every quarantine-copy name this family has ever produced —
|
|
270
|
+
* `<file>.corrupt` (current fixed name), `<file>.corrupt.<epoch>`, and the
|
|
271
|
+
* v10-05 legacy `<file>.corrupt-<epoch>-<rand>` series. A user support file
|
|
272
|
+
* keeps a final extension (`.corrupt-backup.md`) and therefore stays. */
|
|
273
|
+
const CORRUPT_COPY_RE = /\.corrupt(\.\d+|-\d+-[0-9a-z]+)?$/;
|
|
274
|
+
/**
|
|
275
|
+
* V27 G1.3: the error `commitTmp` throws when the rename landed but the parent
|
|
276
|
+
* directory fsync failed — the bytes ARE visible, only their durability is
|
|
277
|
+
* unconfirmed. A consumer that treats it as a plain failure reports "not
|
|
278
|
+
* written" for a write that happened (and a two-phase caller may try to roll
|
|
279
|
+
* back a visible write). Every transaction consumer must therefore treat this
|
|
280
|
+
* shape as SUCCESS-with-warning, never as a rejection.
|
|
281
|
+
*/
|
|
282
|
+
export function isCommittedWarning(error) {
|
|
283
|
+
return error?.committed === true;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Build the Node IO backend: plain fs promises, durable tmp + rename commits (crash atomicity) and
|
|
287
|
+
* ONE concurrency guarantee of its own — writes to the same path are serialized in-process (see
|
|
288
|
+
* `inProcessWrite`). G2 S4 removed the cross-process write lock and its takeover protocol; the
|
|
289
|
+
* plugin path gets cross-process safety from the platform provider (`ctx.fs`).
|
|
290
|
+
*/
|
|
291
|
+
export function nodeEvolutionIo() {
|
|
292
|
+
/**
|
|
293
|
+
* v43 S1-4: delegates to the canonical `isMissingPath` (probe.ts) — one
|
|
294
|
+
* definition for the whole family instead of a second copy of the same two
|
|
295
|
+
* codes. EISDIR deliberately stays OUT of that predicate: a directory
|
|
296
|
+
* squatting on a file path is not "absent" — rotation and event reads must
|
|
297
|
+
* still see it as malformed (rc.72 G-2), while the SkillLibrary.read boundary
|
|
298
|
+
* absorbs EISDIR into "absent" for its own surface (E-43).
|
|
299
|
+
* @param error - the caught read failure.
|
|
300
|
+
* @returns true only for ENOENT/ENOTDIR.
|
|
301
|
+
*/
|
|
302
|
+
const isMissing = (error) => isMissingPath(error);
|
|
303
|
+
/** True when the pid is alive (single source: `isProcessAlive`). */
|
|
304
|
+
const isAlive = isProcessAlive;
|
|
305
|
+
/**
|
|
306
|
+
* G2 S4: the family write-lock protocol is retired. Concurrency is the PLATFORM’s job now — the
|
|
307
|
+
* plugin path requires `ctx.fs`, whose writes are versioned — so this backend keeps exactly one
|
|
308
|
+
* guarantee of its own: writes to the SAME path inside THIS process are serialized (a promise
|
|
309
|
+
* chain per path: no timers, no lock files, no takeover windows). Cross-process RMW safety comes
|
|
310
|
+
* from the provider; a standalone caller sharing a directory across processes must not rely on
|
|
311
|
+
* this backend alone. The durable tmp + rename commit stays — crash atomicity and mutual
|
|
312
|
+
* exclusion are different concerns.
|
|
313
|
+
*/
|
|
314
|
+
const queues = new Map();
|
|
315
|
+
const inProcessWrite = async (path, run) => {
|
|
316
|
+
const previous = queues.get(path) ?? Promise.resolve();
|
|
317
|
+
const result = previous.then(run, run);
|
|
318
|
+
const settled = result.then(() => { }, () => { });
|
|
319
|
+
queues.set(path, settled);
|
|
320
|
+
try {
|
|
321
|
+
return await result;
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
if (queues.get(path) === settled)
|
|
325
|
+
queues.delete(path);
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
/**
|
|
329
|
+
* Sweep transient files a crashed writer left behind, lazily: once per write, for the directory
|
|
330
|
+
* that write is about to touch.
|
|
331
|
+
* `<target>.<pid>.<rand>.tmp` — the durable-write tmp. This process's own leftover is recycled
|
|
332
|
+
* immediately (a live writer's tmp is a current write, not a crash artifact); a foreign tmp
|
|
333
|
+
* needs an age past an hour AND a dead holder.
|
|
334
|
+
* `.corrupt*` — quarantine copies past their rescue window.
|
|
335
|
+
* G2 S4: the lock and takeover-ticket branches are gone with the write-lock protocol.
|
|
336
|
+
*/
|
|
337
|
+
const sweepStaleTmps = async (path) => {
|
|
338
|
+
const dir = dirname(path);
|
|
339
|
+
const base = basename(path);
|
|
340
|
+
let entries;
|
|
341
|
+
try {
|
|
342
|
+
entries = await readdir(dir);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
for (const name of entries) {
|
|
348
|
+
// A1-9 (v18): only a tmp whose EXACT prefix is our base belongs to this target; the old
|
|
349
|
+
// `startsWith(prefix) && endsWith('.tmp')` shape also matched a SIBLING target's tmp.
|
|
350
|
+
const tmpMatch = /^(.*)\.(\d+)\.([0-9a-f]+)\.tmp$/.exec(name);
|
|
351
|
+
if (tmpMatch !== null && tmpMatch[1] === base) {
|
|
352
|
+
const tmpPath = join(dir, name);
|
|
353
|
+
const holder = Number(tmpMatch[2]);
|
|
354
|
+
try {
|
|
355
|
+
const st = await stat(tmpPath);
|
|
356
|
+
const deadHolder = !Number.isInteger(holder) || holder <= 0 || !isAlive(holder);
|
|
357
|
+
const selfLeftover = holder === process.pid;
|
|
358
|
+
if (selfLeftover || (Date.now() - st.mtimeMs > 3_600_000 && deadHolder)) {
|
|
359
|
+
await rm(tmpPath, { force: true });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
// The tmp vanished (or a race with its reaper); nothing to clean.
|
|
364
|
+
}
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
// S-10: a quarantine copy past the rescue window is removed (best-effort: a vanished file
|
|
368
|
+
// needs no cleanup). A1-1 (v18): the namespace layer reserves `.corrupt`/`.tmp` suffixes.
|
|
369
|
+
// Only names belonging to THIS target's series (the same `base.` prefix filter the tmps get):
|
|
370
|
+
// without it a sibling's quarantine copy was fair game.
|
|
371
|
+
if (name.startsWith(base + '.') && CORRUPT_COPY_RE.test(name)) {
|
|
372
|
+
const corruptPath = join(dir, name);
|
|
373
|
+
try {
|
|
374
|
+
const st = await stat(corruptPath);
|
|
375
|
+
if (Date.now() - st.mtimeMs > CORRUPT_SWEEP_AGE_MS)
|
|
376
|
+
await rm(corruptPath, { force: true });
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
// The copy vanished (or raced its reaper); nothing to clean.
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
return {
|
|
385
|
+
async readText(path) {
|
|
386
|
+
try {
|
|
387
|
+
return await readFile(path, 'utf8');
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
// Only "missing" maps to null (the "not there" signal); any other
|
|
391
|
+
// read failure (EACCES/EIO/EMFILE) surfaces so a caller never
|
|
392
|
+
// mistakes an unreadable store for an empty one and overwrites it.
|
|
393
|
+
if (isMissing(error))
|
|
394
|
+
return null;
|
|
395
|
+
throw error;
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
async writeText(path, content) {
|
|
399
|
+
await mkdir(dirname(path), { recursive: true });
|
|
400
|
+
// S4: serialized in-process; the durable tmp + rename commit is unchanged.
|
|
401
|
+
await inProcessWrite(path, async () => {
|
|
402
|
+
await sweepStaleTmps(path);
|
|
403
|
+
const tmp = await writeDurableTmp(path, content);
|
|
404
|
+
await commitTmp(tmp, path);
|
|
405
|
+
});
|
|
406
|
+
},
|
|
407
|
+
async transact(path, task) {
|
|
408
|
+
await mkdir(dirname(path), { recursive: true });
|
|
409
|
+
await inProcessWrite(path, async () => {
|
|
410
|
+
await sweepStaleTmps(path);
|
|
411
|
+
let current;
|
|
412
|
+
try {
|
|
413
|
+
current = await readFile(path, 'utf8');
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
// Only "missing" maps to null; a real read failure surfaces so an unreadable store is
|
|
417
|
+
// never treated as empty and overwritten.
|
|
418
|
+
if (isMissing(error))
|
|
419
|
+
current = null;
|
|
420
|
+
else
|
|
421
|
+
throw error;
|
|
422
|
+
}
|
|
423
|
+
const next = await task(current);
|
|
424
|
+
if (next === null) {
|
|
425
|
+
await rm(path, { force: true });
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
// A byte-identical result is a no-op: no tmp/rename churn and no misleading "written".
|
|
429
|
+
if (next === current)
|
|
430
|
+
return;
|
|
431
|
+
const tmp = await writeDurableTmp(path, next);
|
|
432
|
+
await commitTmp(tmp, path);
|
|
433
|
+
});
|
|
434
|
+
},
|
|
435
|
+
async remove(path) {
|
|
436
|
+
await rm(path, { recursive: true, force: true });
|
|
437
|
+
},
|
|
438
|
+
async list(path) {
|
|
439
|
+
// "Missing" reads as empty; any other failure (EACCES/EIO) surfaces so a
|
|
440
|
+
// caller never mistakes an unreadable tree for an empty one (rc.50 P2-4).
|
|
441
|
+
try {
|
|
442
|
+
return await readdir(path);
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
if (isMissing(error))
|
|
446
|
+
return [];
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
async exists(path) {
|
|
451
|
+
try {
|
|
452
|
+
await stat(path);
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
catch (error) {
|
|
456
|
+
if (isMissing(error))
|
|
457
|
+
return false;
|
|
458
|
+
throw error;
|
|
459
|
+
}
|
|
460
|
+
},
|
|
461
|
+
async rename(path, destination) {
|
|
462
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
463
|
+
// P2-6 (v11): the seam's rename routes through the SAME transient
|
|
464
|
+
// EPERM/EBUSY retry as the writeText/transact commit path (C-28) —
|
|
465
|
+
// legacy retirement and archive/restore moves were bare-rename and
|
|
466
|
+
// could fail on antivirus/indexer hold while the commit path retried.
|
|
467
|
+
await renameWithRetry(path, destination);
|
|
468
|
+
},
|
|
469
|
+
async copy(path, destination) {
|
|
470
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
471
|
+
await cp(path, destination, { recursive: true, force: true });
|
|
472
|
+
},
|
|
473
|
+
async size(path) {
|
|
474
|
+
try {
|
|
475
|
+
return (await stat(path)).size;
|
|
476
|
+
}
|
|
477
|
+
catch (error) {
|
|
478
|
+
if (isMissing(error))
|
|
479
|
+
return null;
|
|
480
|
+
throw error;
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
async isSymlink(path) {
|
|
484
|
+
try {
|
|
485
|
+
return (await lstat(path)).isSymbolicLink();
|
|
486
|
+
}
|
|
487
|
+
catch (error) {
|
|
488
|
+
// G7 guard (P3-8, v14): `null` means "probe not applicable", which for
|
|
489
|
+
// this guard is exactly a MISSING path. A real lstat failure
|
|
490
|
+
// (EACCES/EIO/…) must not read as "not a symlink" — the caller would
|
|
491
|
+
// then move a link it was supposed to refuse. Same discipline as the
|
|
492
|
+
// sibling probes (`size`/`mtime`/`exists`).
|
|
493
|
+
if (isMissing(error))
|
|
494
|
+
return null;
|
|
495
|
+
throw error;
|
|
496
|
+
}
|
|
497
|
+
},
|
|
498
|
+
async mtime(path) {
|
|
499
|
+
try {
|
|
500
|
+
return (await stat(path)).mtimeMs;
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
if (isMissing(error))
|
|
504
|
+
return null;
|
|
505
|
+
throw error;
|
|
506
|
+
}
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
const defaultFs = {
|
|
511
|
+
writeFileSync: (path, data) => { writeFileSync(path, data); },
|
|
512
|
+
existsSync: path => existsSync(path),
|
|
513
|
+
copyFileSync: (from, to) => { copyFileSync(from, to); },
|
|
514
|
+
renameSync: (from, to) => { renameSync(from, to); },
|
|
515
|
+
rmSync: (path, options) => { rmSync(path, options); },
|
|
516
|
+
// N14: only a MISSING file is "no mtime" (null). Any other stat failure
|
|
517
|
+
// rejects, so probeMtime() reports it as unknown instead of as absent.
|
|
518
|
+
mtime: (path) => {
|
|
519
|
+
try {
|
|
520
|
+
return statSync(path).mtimeMs;
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
// v43 S1-4: the canonical predicate (core/probe.ts) — one definition of
|
|
524
|
+
// "this read failure means it is not there" instead of a second copy.
|
|
525
|
+
if (isMissingPath(error))
|
|
526
|
+
return null;
|
|
527
|
+
throw error;
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
/**
|
|
532
|
+
* S6.3 (E-40): atomically replace a set of files within one directory.
|
|
533
|
+
* Each file is staged to a sibling `<name>.tmp`, then renamed into place
|
|
534
|
+
* (atomic on the same filesystem); a pre-existing file keeps a single
|
|
535
|
+
* `<name>.bak`. A failure at ANY staging step removes the staged temps and
|
|
536
|
+
* leaves the previous files untouched — a half-written target is impossible
|
|
537
|
+
* up to the staging and backup phases.
|
|
538
|
+
*
|
|
539
|
+
* F-211: the rename COMMIT phase does not carry the same guarantee. When a
|
|
540
|
+
* filesystem refuses to overwrite the destination, the code removes it and
|
|
541
|
+
* retries the rename; if the retry ALSO fails the destination is gone. The
|
|
542
|
+
* single `.bak` created above still holds the previous content, so the target
|
|
543
|
+
* is restored from it (best-effort) before the original rename error is
|
|
544
|
+
* rethrown, so a failed commit never leaves the file missing.
|
|
545
|
+
*
|
|
546
|
+
* V4-17: the `.bak` is refreshed to the committed content after every
|
|
547
|
+
* successful commit, so a later failed commit always recovers to the most
|
|
548
|
+
* recent good generation (a once-at-first-install `.bak` could otherwise
|
|
549
|
+
* restore content several installs old). The recovery error names the `.bak`
|
|
550
|
+
* source (and mtime when available) so the caller knows the generation it
|
|
551
|
+
* restored from.
|
|
552
|
+
*/
|
|
553
|
+
export function atomicWriteFiles(targetDir, writes, fs = defaultFs,
|
|
554
|
+
// F3 (P2-19, v11): warnings go through the caller's logger (the runtime
|
|
555
|
+
// pipeline), not a raw console.warn — the .bak-refresh failure is a
|
|
556
|
+
// real production event and must be observable in the operator logs.
|
|
557
|
+
logger = console.warn) {
|
|
558
|
+
const stage = (name) => join(targetDir, `${name}.tmp`);
|
|
559
|
+
// V6-42 (0.3.36): a duplicate name makes the commit re-visit the same
|
|
560
|
+
// target: the second rename ENOENTs (the tmp already moved), then the
|
|
561
|
+
// remove-then-rename recovery DELETES the file the first commit just
|
|
562
|
+
// installed and restores an old `.bak` generation — a self-referential,
|
|
563
|
+
// confusing failure. Fail loud on the input instead.
|
|
564
|
+
const dupes = [...new Set(writes.map(write => write.name).filter((name, index, all) => all.indexOf(name) !== index))];
|
|
565
|
+
if (dupes.length > 0) {
|
|
566
|
+
throw new Error(`atomicWriteFiles: duplicate input names: ${dupes.join(', ')}`);
|
|
567
|
+
}
|
|
568
|
+
try {
|
|
569
|
+
for (const { name, content } of writes)
|
|
570
|
+
fs.writeFileSync(stage(name), content);
|
|
571
|
+
for (const { name } of writes) {
|
|
572
|
+
const finalPath = join(targetDir, name);
|
|
573
|
+
const bakPath = join(targetDir, `${name}.bak`);
|
|
574
|
+
// Back up the currently installed file once, so an interrupted commit can
|
|
575
|
+
// fall back to the previous usable composition.
|
|
576
|
+
if (fs.existsSync(finalPath) && !fs.existsSync(bakPath))
|
|
577
|
+
fs.copyFileSync(finalPath, bakPath);
|
|
578
|
+
}
|
|
579
|
+
const committed = [];
|
|
580
|
+
for (const { name } of writes) {
|
|
581
|
+
const finalPath = join(targetDir, name);
|
|
582
|
+
const bakPath = join(targetDir, `${name}.bak`);
|
|
583
|
+
const tmp = stage(name);
|
|
584
|
+
try {
|
|
585
|
+
fs.renameSync(tmp, finalPath);
|
|
586
|
+
committed.push(name);
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
// Some filesystems refuse to overwrite the destination; the single
|
|
590
|
+
// .bak above already holds the previous file, so remove-then-rename is
|
|
591
|
+
// safe here.
|
|
592
|
+
try {
|
|
593
|
+
fs.rmSync(finalPath, { force: true });
|
|
594
|
+
}
|
|
595
|
+
catch (removeError) {
|
|
596
|
+
// V6-28 (0.3.36): a failed remove must not escape WITHOUT the
|
|
597
|
+
// disclosure chain — `committed` is the only trace of a partial
|
|
598
|
+
// install and the recovery narration would be lost (Windows: rename
|
|
599
|
+
// and unlink often fail together with EPERM/EBUSY).
|
|
600
|
+
const already = committed.length > 0 ? `; already committed: ${committed.join(', ')}` : '';
|
|
601
|
+
throw new Error(`atomicWriteFiles: could not remove "${name}" to replace it (${removeError instanceof Error ? removeError.message : String(removeError)})${already}`);
|
|
602
|
+
}
|
|
603
|
+
try {
|
|
604
|
+
fs.renameSync(tmp, finalPath);
|
|
605
|
+
committed.push(name);
|
|
606
|
+
}
|
|
607
|
+
catch (renameError) {
|
|
608
|
+
// F-211: the retry failed AFTER the target was removed — restore it
|
|
609
|
+
// from .bak before surfacing the error, so the file is not left
|
|
610
|
+
// missing. Best-effort: if the restore also fails, note that and
|
|
611
|
+
// throw the original rename error so the caller still knows why.
|
|
612
|
+
// V5-17 (0.3.31): a multi-file commit that fails midway is PARTIAL —
|
|
613
|
+
// the error must disclose which files already landed (the caller is
|
|
614
|
+
// otherwise told only about the file that failed).
|
|
615
|
+
const already = committed.length > 0 ? `; already committed: ${committed.join(', ')}` : '';
|
|
616
|
+
if (fs.existsSync(bakPath)) {
|
|
617
|
+
try {
|
|
618
|
+
fs.copyFileSync(bakPath, finalPath);
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
throw new Error(`atomicWriteFiles: "${name}" was removed but recovery from ${bakPath} failed (${renameError instanceof Error ? renameError.message : String(renameError)}); verify the file manually${already}`);
|
|
622
|
+
}
|
|
623
|
+
// V4-17: recovery succeeded — name the source generation so a caller
|
|
624
|
+
// is not misled into thinking the attempt actually landed.
|
|
625
|
+
const bakMtime = fs.mtime?.(bakPath);
|
|
626
|
+
const when = bakMtime !== null && bakMtime !== undefined && Number.isFinite(bakMtime) ? ` (bak mtime ${new Date(bakMtime).toISOString()})` : '';
|
|
627
|
+
throw new Error(`atomicWriteFiles: "${name}" was removed during commit and restored from the last good generation ${bakPath}${when}; original error: ${renameError instanceof Error ? renameError.message : String(renameError)}${already}`);
|
|
628
|
+
}
|
|
629
|
+
throw new Error(`${renameError instanceof Error ? renameError.message : String(renameError)}${already}`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
// V4-17: a successful commit refreshes each .bak to the content that just
|
|
634
|
+
// landed, so a later failed commit recovers to the most recent good
|
|
635
|
+
// generation rather than one several installs ago (the backup phase above
|
|
636
|
+
// only ever created a .bak on first install).
|
|
637
|
+
// V5-17 (0.3.31): the refresh is best-effort maintenance — a failure here
|
|
638
|
+
// must NOT turn a successful install into a reported failure, and must not
|
|
639
|
+
// abort mid-loop (which would split the bak pair: some fresh, some old).
|
|
640
|
+
for (const { name } of writes) {
|
|
641
|
+
const finalPath = join(targetDir, name);
|
|
642
|
+
try {
|
|
643
|
+
if (fs.existsSync(finalPath))
|
|
644
|
+
fs.copyFileSync(finalPath, join(targetDir, `${name}.bak`));
|
|
645
|
+
}
|
|
646
|
+
catch (refreshError) {
|
|
647
|
+
logger(`atomicWriteFiles: committed "${name}" but failed to refresh its .bak (${refreshError instanceof Error ? refreshError.message : String(refreshError)}); a later failed commit will recover to an OLDER generation`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
catch (error) {
|
|
652
|
+
// OPT-24 (2026-09): the cleanup loop runs INSIDE the catch — an rmSync
|
|
653
|
+
// that hits EPERM/EBUSY (Windows: AV indexer, open handle; force only
|
|
654
|
+
// suppresses ENOENT) escaped the catch and REPLACED the carefully built
|
|
655
|
+
// recovery diagnostic (including the already-committed disclosure), and
|
|
656
|
+
// left the remaining staged temps behind. Cleanup failures are now
|
|
657
|
+
// collected and appended to the original error; every temp gets its
|
|
658
|
+
// removal attempt.
|
|
659
|
+
const cleanupFailures = [];
|
|
660
|
+
for (const { name } of writes) {
|
|
661
|
+
try {
|
|
662
|
+
fs.rmSync(stage(name), { force: true, recursive: true });
|
|
663
|
+
}
|
|
664
|
+
catch (cleanupError) {
|
|
665
|
+
cleanupFailures.push(`${name}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (cleanupFailures.length > 0) {
|
|
669
|
+
const original = error instanceof Error ? error.message : String(error);
|
|
670
|
+
throw new Error(`${original} (staged-temp cleanup also failed: ${cleanupFailures.join('; ')})`);
|
|
671
|
+
}
|
|
672
|
+
throw error;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
//# sourceMappingURL=io.js.map
|