@twinsearth/w2m-dsh-plugin 1.9.11 → 1.9.14

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.
@@ -283,7 +283,7 @@ const MAX_STAGES = 16;
283
283
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
284
284
  * overrides it at runtime for tests and packagers.
285
285
  */
286
- const PLUGIN_VERSION = '1.9.11';
286
+ const PLUGIN_VERSION = '1.9.14';
287
287
 
288
288
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
289
289
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -4249,6 +4249,7 @@ export async function apply(ctx, config = {}) {
4249
4249
  const originMachineId =
4250
4250
  typeof p2p.machineId === 'string' && p2p.machineId !== '' ? p2p.machineId : null;
4251
4251
 
4252
+
4252
4253
  /**
4253
4254
  * v0.4.x: the punch this dispatch will use, published with the task so the executor can join
4254
4255
  * the same session instead of waiting to be reached. See {@link buildP2POfferBlock} for why the
@@ -4313,7 +4314,33 @@ export async function apply(ctx, config = {}) {
4313
4314
  p2p: p2pOffer,
4314
4315
  };
4315
4316
 
4316
- const result = await request({
4317
+ // v2/S1: a narrowed dispatch is revalidated against the relay roster immediately before the
4318
+ // POST, so a machine that went away after the composer selection is a named refusal rather
4319
+ // than a task that expires with no executor.
4320
+ const narrowedTo = selectionAppliedAs === 'executor_machine_id' || selectionAppliedAs === 'target_machines'
4321
+ ? (selection?.machine_id ?? null)
4322
+ : (executorMachineId ?? null);
4323
+ if (narrowedTo !== null) {
4324
+ // A roster read is a best-effort guard: if it fails, the relay itself still decides and the
4325
+ // post-dispatch lease check below remains the authority on whether the narrowing held.
4326
+ let preflight = null;
4327
+ try {
4328
+ preflight = await readFleet({ rabbitUrl: base, token, localMachineId: null, signal: exec?.signal, request });
4329
+ } catch { preflight = null; }
4330
+ if (preflight.ok === true) {
4331
+ const listed = (preflight.devices ?? []).find((device) => device?.machine_id === narrowedTo);
4332
+ const online = listed !== undefined && (listed.state === 'online' || listed.state === undefined);
4333
+ if (!online) {
4334
+ throw new W2MError(
4335
+ 'TARGET_UNAVAILABLE',
4336
+ `TARGET_UNAVAILABLE: the target machine ${narrowedTo} is not online on the relay right now; nothing was dispatched and no other machine was chosen`,
4337
+ { hint: 'pick another machine in the composer, or clear the selection to dispatch to the whole fleet' },
4338
+ );
4339
+ }
4340
+ }
4341
+ }
4342
+
4343
+ const result = await request({
4317
4344
  rabbitUrl: base,
4318
4345
  token,
4319
4346
  signal: exec?.signal,
@@ -4346,7 +4373,29 @@ export async function apply(ctx, config = {}) {
4346
4373
  );
4347
4374
  }
4348
4375
 
4349
- /**
4376
+ // v2/S1: the relay's own lease list is the proof that the narrowing was honoured. A lease on a
4377
+ // machine that was not named means the dispatch widened (old relay ignoring the field, or a
4378
+ // routing bug): cancel and refuse rather than let the wrong machines run.
4379
+ if (narrowedTo !== null) {
4380
+ const leases = Array.isArray(result.json?.leases) ? result.json.leases : [];
4381
+ const executing = leases.filter((lease) => lease?.state !== 'observing');
4382
+ const foreign = executing.filter((lease) => lease?.machine_id !== narrowedTo);
4383
+ if (foreign.length > 0) {
4384
+ const taskId = result.json?.task_id ?? null;
4385
+ if (taskId !== null) {
4386
+ try {
4387
+ await request({ rabbitUrl: base, token, signal: exec?.signal, method: 'POST', pathname: '/v1/tasks/' + encodeURIComponent(taskId) + '/cancel', body: { reason: 'SELECTION_NOT_HONOURED' } });
4388
+ } catch { /* the refusal below is still the answer */ }
4389
+ }
4390
+ throw new W2MError(
4391
+ 'SELECTION_NOT_HONOURED',
4392
+ 'SELECTION_NOT_HONOURED: this dispatch named ' + narrowedTo + ', but the relay leased ' + (foreign.map((lease) => lease.machine_id).join(', ') || 'no machine') + '; the task was cancelled',
4393
+ { hint: 'upgrade the relay to a version that honours target_machines/executor_machine_id, or clear the selection' },
4394
+ );
4395
+ }
4396
+ }
4397
+
4398
+ /**
4350
4399
  * v0.4.0 §7: best-effort direct pushes, after the relay has the task.
4351
4400
  *
4352
4401
  * Order matters and is deliberate. The task exists on the relay *before* anything is punched,
@@ -86,6 +86,13 @@ export function registerPluginUpdateRoutes(ctx, deps) {
86
86
  const result = await updater.check('manual');
87
87
  const installedTo = typeof result?.to === 'string' && result.to !== '' ? result.to : null;
88
88
  const installed = result?.outcome === 'installed' || installedTo !== null && installedTo !== current;
89
+ // The GitHub release download is blocked on some networks ("fetch failed"), while the npm
90
+ // registry is reachable. Before reporting a failure, install the same version from the
91
+ // registry tarball, whose integrity field is verified before pnpm sees it.
92
+ if (installed !== true) {
93
+ const fallback = await installFromNpmFallback({ profileDir, current, version: checked.latest ?? current });
94
+ if (fallback !== null) return Response.json(fallback.body, { headers: NO_STORE });
95
+ }
89
96
  return Response.json({
90
97
  ok: result?.ok === true && installed === true,
91
98
  outcome: result?.outcome ?? 'unknown',
@@ -116,3 +123,27 @@ async function defaultUpdater({ profileDir, currentVersion }) {
116
123
  log: () => {},
117
124
  });
118
125
  }
126
+
127
+ /** Install the same version from the npm registry when the GitHub release download fails. */
128
+ async function installFromNpmFallback({ profileDir, current, version }) {
129
+ try {
130
+ const { fetchNpmTarball } = await import('./npm-update.mjs');
131
+ const { installUpdate } = await import('./update-install.mjs');
132
+ const { tmpdir } = await import('node:os');
133
+ const fetched = await fetchNpmTarball({ version, destDir: join(tmpdir(), 'w2m-npm-update-' + Date.now()) });
134
+ if (fetched.ok !== true) return null;
135
+ const result = await installUpdate({ profileDir, tarballPath: fetched.path, expectedSha256: fetched.sha256 });
136
+ if (result?.ok !== true) return null;
137
+ return {
138
+ body: {
139
+ ok: true,
140
+ outcome: 'installed',
141
+ installedVia: 'npm-tarball',
142
+ from: result.from ?? current,
143
+ to: result.to ?? version,
144
+ installed: true,
145
+ restart_required: true,
146
+ },
147
+ };
148
+ } catch { return null; }
149
+ }
@@ -55,6 +55,23 @@ export function projectResultEnvelope(envelope) {
55
55
  dropped.push(field);
56
56
  }
57
57
  }
58
+ // NESTED: pipeline.stages[].command_argv and friends are content too. The top-level sweep above
59
+ // cannot see them, and a real ledger line carries exactly that shape.
60
+ const NESTED_CONTENT = [...CONTENT_FIELDS, 'changed_files'];
61
+ const walk = (value, path) => {
62
+ if (value === null || typeof value !== 'object') return;
63
+ if (Array.isArray(value)) { value.forEach((entry, index) => walk(entry, path + '[' + index + ']')); return; }
64
+ for (const key of Object.keys(value)) {
65
+ const keyPath = path === '' ? key : path + '.' + key;
66
+ if (NESTED_CONTENT.includes(key)) {
67
+ delete value[key];
68
+ dropped.push(keyPath);
69
+ continue;
70
+ }
71
+ walk(value[key], keyPath);
72
+ }
73
+ };
74
+ walk(projected, '');
58
75
  return { projected, dropped, argv_hash: argvHash };
59
76
  }
60
77
 
@@ -62,11 +79,16 @@ export function projectResultEnvelope(envelope) {
62
79
  export function projectLedgerLine(line) {
63
80
  let record;
64
81
  try { record = JSON.parse(line); } catch { return { changed: false, line }; }
65
- const target = record?.result ?? record?.envelope;
66
- if (target === null || typeof target !== 'object') return { changed: false, line };
67
- const { projected, dropped } = projectResultEnvelope(target);
82
+ // Real ledger lines nest the envelope one level down: {type:'result.stored', result:{envelope:{...}}}.
83
+ // Handle that shape first, then the flat result, then a bare envelope record.
84
+ let holder = null;
85
+ if (record?.result && typeof record.result === 'object' && record.result.envelope && typeof record.result.envelope === 'object') holder = { parent: record.result, key: 'envelope' };
86
+ else if (record?.result && typeof record.result === 'object') holder = { parent: record, key: 'result' };
87
+ else if (record?.envelope && typeof record.envelope === 'object') holder = { parent: record, key: 'envelope' };
88
+ if (holder === null) return { changed: false, line };
89
+ const { projected, dropped } = projectResultEnvelope(holder.parent[holder.key]);
68
90
  if (dropped.length === 0) return { changed: false, line };
69
- if (record.result !== undefined) record.result = projected; else record.envelope = projected;
91
+ holder.parent[holder.key] = projected;
70
92
  record.projection = { dropped, at: new Date().toISOString() };
71
93
  return { changed: true, line: JSON.stringify(record) };
72
94
  }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Relay blob purge: retire the data-plane store that old relays (1.6.4) still accepted.
3
+ *
4
+ * Layout note (measured on a real relay): blobs live at `<state>/blobs/<aa>/<sha256>`, i.e. ONE
5
+ * level of fan-out directories, and `blob-sessions/` may hold in-flight chunks. The earlier
6
+ * scrubBlobs() only looked at the top level, which is why a dry run against a real state directory
7
+ * reported zero files while the relay was still holding megabytes.
8
+ *
9
+ * Rules:
10
+ * - dry-run by default; `apply` is required to touch anything
11
+ * - delete or quarantine (move outside the relay state tree); a manifest of what was removed is
12
+ * written next to the quarantine so an audit can prove what existed
13
+ * - after the purge the directory is re-listed; a non-empty result is `BLOBS_REAPPEARED`
14
+ * (that means something is still writing, e.g. an old relay that was not retired first)
15
+ * - stop the relay before applying: it keeps appending while running
16
+ */
17
+ import {
18
+ existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync,
19
+ } from 'node:fs';
20
+ import { createHash } from 'node:crypto';
21
+ import { dirname, join, relative } from 'node:path';
22
+
23
+ export const PURGE_CODES = Object.freeze({
24
+ MISSING: 'BLOBS_MISSING',
25
+ NOT_A_DIR: 'BLOBS_NOT_A_DIR',
26
+ REAPPEARED: 'BLOBS_REAPPEARED',
27
+ QUARANTINE_FAILED: 'QUARANTINE_FAILED',
28
+ });
29
+
30
+ const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
31
+
32
+ /** Recursively list blob files (and in-flight session dirs when asked). */
33
+ export function listBlobs(blobDir, { includeSessions = true } = {}) {
34
+ if (!existsSync(blobDir)) return { ok: false, code: PURGE_CODES.MISSING, files: [] };
35
+ if (!statSync(blobDir).isDirectory()) return { ok: false, code: PURGE_CODES.NOT_A_DIR, files: [] };
36
+ const files = [];
37
+ const walk = (dir) => {
38
+ for (const name of readdirSync(dir)) {
39
+ const path = join(dir, name);
40
+ const stat = statSync(path);
41
+ if (stat.isDirectory()) {
42
+ const rel = relative(blobDir, path).split('\\').join('/');
43
+ if (!includeSessions && rel.startsWith('blob-sessions')) continue;
44
+ walk(path);
45
+ continue;
46
+ }
47
+ if (!stat.isFile()) continue;
48
+ files.push({ rel: relative(blobDir, path).split('\\').join('/'), size: stat.size });
49
+ }
50
+ };
51
+ walk(blobDir);
52
+ files.sort((a, b) => a.rel.localeCompare(b.rel));
53
+ return { ok: true, files };
54
+ }
55
+
56
+ /** Prune empty directories under `dir`, leaving `dir` itself in place. */
57
+ function pruneEmpty(dir) {
58
+ for (const name of readdirSync(dir)) {
59
+ const path = join(dir, name);
60
+ if (statSync(path).isDirectory()) {
61
+ pruneEmpty(path);
62
+ if (readdirSync(path).length === 0) rmdirSync(path);
63
+ }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Purge the blob store.
69
+ *
70
+ * @param {string} blobDir `<state>/blobs`
71
+ * @param {{apply?: boolean, quarantineDir?: string|null, now?: number, includeSessions?: boolean}} [options]
72
+ */
73
+ export function purgeBlobs(blobDir, options = {}) {
74
+ const listed = listBlobs(blobDir, { includeSessions: options.includeSessions !== false });
75
+ if (listed.ok !== true) return { ok: false, ...listed, exists: false, count: 0, bytes: 0 };
76
+ const files = listed.files;
77
+ const bytes = files.reduce((sum, file) => sum + file.size, 0);
78
+ const base = {
79
+ ok: true,
80
+ dir: blobDir,
81
+ exists: true,
82
+ count: files.length,
83
+ bytes,
84
+ sample: files.slice(0, 5).map((file) => file.rel),
85
+ };
86
+ if (options.apply !== true) return { ...base, dry_run: true, removed: 0, quarantined: 0 };
87
+ if (files.length === 0) return { ...base, dry_run: false, removed: 0, quarantined: 0, remaining: 0 };
88
+
89
+ const now = Number.isInteger(options.now) ? options.now : Date.now();
90
+ let quarantine = null;
91
+ let manifest = null;
92
+ if (typeof options.quarantineDir === 'string' && options.quarantineDir !== '') {
93
+ quarantine = join(options.quarantineDir, 'purge-' + now);
94
+ try {
95
+ for (const file of files) {
96
+ const target = join(quarantine, file.rel);
97
+ mkdirSync(dirname(target), { recursive: true });
98
+ renameSync(join(blobDir, file.rel), target);
99
+ }
100
+ manifest = join(quarantine, 'manifest.json');
101
+ writeFileSync(
102
+ manifest,
103
+ JSON.stringify(
104
+ {
105
+ at: new Date(now).toISOString(),
106
+ source: blobDir,
107
+ count: files.length,
108
+ bytes,
109
+ files: files.map((file) => ({ rel: file.rel, size: file.size, sha256: sha256(readFileSync(join(quarantine, file.rel))) })),
110
+ },
111
+ null,
112
+ 2,
113
+ ) + '\n',
114
+ 'utf8',
115
+ );
116
+ } catch (error) {
117
+ return { ...base, ok: false, code: PURGE_CODES.QUARANTINE_FAILED, error: String(error) };
118
+ }
119
+ } else {
120
+ for (const file of files) unlinkSync(join(blobDir, file.rel));
121
+ }
122
+ pruneEmpty(blobDir);
123
+
124
+ const remaining = listBlobs(blobDir).files;
125
+ const result = {
126
+ ...base,
127
+ dry_run: false,
128
+ removed: quarantine === null ? files.length : 0,
129
+ quarantined: quarantine === null ? 0 : files.length,
130
+ quarantine,
131
+ manifest,
132
+ remaining: remaining.length,
133
+ remaining_bytes: remaining.reduce((sum, file) => sum + file.size, 0),
134
+ };
135
+ if (remaining.length > 0) return { ...result, ok: false, code: PURGE_CODES.REAPPEARED };
136
+ return result;
137
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Relay ledger repair: quarantine corrupt JSONL lines and atomically rewrite the rest.
3
+ *
4
+ * The relay's ledger is append-only (`<state>/ledger.jsonl`, one JSON object per line). A crash
5
+ * during an append can leave a torn line, and `Persistence` reports it rather than guessing:
6
+ * `ledger_lines_skipped` and a `warn(ledger.jsonl, ...)` entry survive every restart. This tool is
7
+ * the operator half of that report.
8
+ *
9
+ * Rules (deliberately conservative):
10
+ * - a line that `JSON.parse` rejects is NEVER repaired by guessing; it is moved to a quarantine
11
+ * file byte for byte, so the operator can inspect what was lost
12
+ * - dry-run is the default; writing needs `apply: true`
13
+ * - the original is copied to `<file>.bak-<timestamp>` (mode 0600) before the atomic rename
14
+ * - the read is bracketed by `stat`: if the relay appended while we were reading, the tool
15
+ * refuses with `LEDGER_BUSY` instead of silently dropping the new line
16
+ */
17
+ import {
18
+ chmodSync,
19
+ chownSync,
20
+ closeSync,
21
+ copyFileSync,
22
+ existsSync,
23
+ fsyncSync,
24
+ openSync,
25
+ readFileSync,
26
+ renameSync,
27
+ statSync,
28
+ unlinkSync,
29
+ writeSync,
30
+ } from 'node:fs';
31
+ import { createHash } from 'node:crypto';
32
+ import { dirname, join } from 'node:path';
33
+
34
+ export const LEDGER_REPAIR_CODES = Object.freeze({
35
+ MISSING: 'LEDGER_MISSING',
36
+ BUSY: 'LEDGER_BUSY',
37
+ NOT_A_FILE: 'LEDGER_NOT_A_FILE',
38
+ WRITE_FAILED: 'LEDGER_WRITE_FAILED',
39
+ });
40
+
41
+ const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
42
+
43
+ /** Write bytes and fsync them, so a rename cannot publish a page the disk has not seen. */
44
+ function writeDurable(file, data, mode) {
45
+ const fd = openSync(file, 'w', mode);
46
+ try {
47
+ writeSync(fd, data);
48
+ fsyncSync(fd);
49
+ } finally {
50
+ closeSync(fd);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Inspect (and optionally repair) a ledger file.
56
+ *
57
+ * @param {string} file absolute or relative path to `ledger.jsonl`
58
+ * @param {{apply?: boolean, now?: number, quarantineDir?: string}} [options]
59
+ * @returns {object} report; `ok:false` carries one of {@link LEDGER_REPAIR_CODES}
60
+ */
61
+ export function repairLedger(file, options = {}) {
62
+ if (typeof file !== 'string' || file === '') throw new TypeError('repairLedger: a file path is required');
63
+ if (!existsSync(file)) return { ok: false, code: LEDGER_REPAIR_CODES.MISSING, path: file };
64
+ const before = statSync(file);
65
+ if (!before.isFile()) return { ok: false, code: LEDGER_REPAIR_CODES.NOT_A_FILE, path: file };
66
+
67
+ const mode = before.mode & 0o777;
68
+ const raw = readFileSync(file);
69
+ const afterRead = statSync(file);
70
+ if (afterRead.mtimeMs !== before.mtimeMs || afterRead.size !== before.size) {
71
+ return { ok: false, code: LEDGER_REPAIR_CODES.BUSY, path: file, size: before.size, size_now: afterRead.size };
72
+ }
73
+
74
+ const text = raw.toString('utf8');
75
+ const hasFinalNewline = text.endsWith('\n');
76
+ const lines = text.split('\n');
77
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
78
+
79
+ const valid = [];
80
+ const invalid = [];
81
+ lines.forEach((line, index) => {
82
+ if (line.trim() === '') {
83
+ // A blank line carries no record; dropping it changes no replay outcome.
84
+ invalid.push({ line: index + 1, reason: 'EMPTY_LINE', raw: line });
85
+ return;
86
+ }
87
+ try {
88
+ JSON.parse(line);
89
+ valid.push(line);
90
+ } catch (error) {
91
+ invalid.push({ line: index + 1, reason: error instanceof Error ? error.message : String(error), raw: line });
92
+ }
93
+ });
94
+
95
+ const changed = invalid.length > 0;
96
+ const base = {
97
+ ok: true,
98
+ path: file,
99
+ bytes_before: raw.length,
100
+ sha256_before: sha256(raw),
101
+ total_lines: lines.length,
102
+ valid_lines: valid.length,
103
+ invalid_count: invalid.length,
104
+ invalid: invalid.map((entry) => ({ line: entry.line, reason: entry.reason })),
105
+ changed,
106
+ };
107
+ if (!changed) return { ...base, dry_run: options.apply !== true, written: false };
108
+ if (options.apply !== true) {
109
+ return {
110
+ ...base,
111
+ dry_run: true,
112
+ written: false,
113
+ note: 'dry run: pass apply:true (CLI --apply) to quarantine the invalid lines and rewrite the file',
114
+ };
115
+ }
116
+
117
+ const now = Number.isInteger(options.now) ? options.now : Date.now();
118
+ const backup = `${file}.bak-${now}`;
119
+ try {
120
+ copyFileSync(file, backup);
121
+ chmodSync(backup, mode);
122
+ try { chownSync(backup, before.uid, before.gid); } catch { /* unprivileged run keeps the caller's owner */ }
123
+ } catch (error) {
124
+ return { ...base, dry_run: false, written: false, ok: false, code: LEDGER_REPAIR_CODES.WRITE_FAILED, error: String(error) };
125
+ }
126
+
127
+ const quarantineDir = options.quarantineDir ?? dirname(file);
128
+ const quarantine = join(quarantineDir, `ledger.corrupt-${now}.jsonl`);
129
+ try {
130
+ writeDurable(quarantine, Buffer.from(invalid.map((entry) => entry.raw).join('\n') + '\n', 'utf8'), 0o600);
131
+ } catch (error) {
132
+ return { ...base, dry_run: false, written: false, ok: false, code: LEDGER_REPAIR_CODES.WRITE_FAILED, error: String(error), backup };
133
+ }
134
+
135
+ const next = valid.length === 0 ? '' : valid.join('\n') + (hasFinalNewline ? '\n' : '');
136
+ const tmp = `${file}.repair-${now}.tmp`;
137
+ try {
138
+ writeDurable(tmp, Buffer.from(next, 'utf8'), mode);
139
+ // Keep the service account as the owner: a relay that runs as `w2m` must still be able to
140
+ // read the file after root repaired it (this exact break happened on Vultr).
141
+ try { chownSync(tmp, before.uid, before.gid); } catch { /* unprivileged */ }
142
+ renameSync(tmp, file);
143
+ } catch (error) {
144
+ try { if (existsSync(tmp)) unlinkSync(tmp); } catch { /* best effort */ }
145
+ return { ...base, dry_run: false, written: false, ok: false, code: LEDGER_REPAIR_CODES.WRITE_FAILED, error: String(error), backup, quarantine };
146
+ }
147
+
148
+ const written = readFileSync(file);
149
+ const reread = written.toString('utf8').split('\n').filter((line) => line.trim() !== '');
150
+ for (const line of reread) {
151
+ try { JSON.parse(line); } catch (error) {
152
+ return {
153
+ ...base, dry_run: false, written: true, backup, quarantine,
154
+ ok: false, code: LEDGER_REPAIR_CODES.WRITE_FAILED,
155
+ error: 'post-write verification failed: ' + (error instanceof Error ? error.message : String(error)),
156
+ };
157
+ }
158
+ }
159
+
160
+ return {
161
+ ...base,
162
+ dry_run: false,
163
+ written: true,
164
+ backup,
165
+ quarantine,
166
+ bytes_after: written.length,
167
+ sha256_after: sha256(written),
168
+ lines_after: reread.length,
169
+ verified: true,
170
+ };
171
+ }
@@ -8,25 +8,11 @@ import {
8
8
  import { createHash } from 'node:crypto';
9
9
  import { join } from 'node:path';
10
10
 
11
+ import { projectLedgerLine } from './p0-projection.mjs';
12
+
11
13
  export const LEDGER_CONTENT_FIELDS = ['stdout_head', 'stdout_tail', 'stderr_head', 'stderr_tail', 'command_argv', 'pipeline_hash_material', 'changed_files'];
12
14
  const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
13
15
 
14
- function projectLedgerLine(line) {
15
- let record;
16
- try { record = JSON.parse(line); } catch { return { changed: false, line }; }
17
- const result = record?.result ?? record?.envelope ?? null;
18
- if (result === null || typeof result !== 'object') return { changed: false, line };
19
- let changed = false;
20
- for (const field of LEDGER_CONTENT_FIELDS) {
21
- if (field in result) { delete result[field]; changed = true; }
22
- }
23
- if ('patch_sha256' in result) { result.patch_sha256 = null; changed = true; }
24
- if ('workspace' in result && typeof result.workspace === 'object' && result.workspace !== null) {
25
- if ('changed_files' in result.workspace) { delete result.workspace.changed_files; changed = true; }
26
- if ('patch_sha256' in result.workspace) { result.workspace.patch_sha256 = null; changed = true; }
27
- }
28
- return { changed, line: changed ? JSON.stringify(record) : line };
29
- }
30
16
 
31
17
  /** 清理 ledger:默认 dry-run;apply 时备份 .bak-<ts> 后原子替换。 */
32
18
  export function scrubLedger(ledgerPath, { dryRun = true, now = Date.now() } = {}) {
@@ -73,11 +59,15 @@ export function scanForContent(rootDir, markers = []) {
73
59
  const hits = [];
74
60
  const walk = (dir) => {
75
61
  for (const name of readdirSync(dir)) {
62
+ // Backups/temporaries are not live state: they hold the bytes the scrub just moved out.
63
+ if (/\.bak-|\.tmp-|\.corrupt-/.test(name)) continue;
76
64
  const path = join(dir, name);
77
65
  const stat = statSync(path);
78
66
  if (stat.isDirectory()) { walk(path); continue; }
79
67
  if (stat.size > 32 * 1024 * 1024) continue;
80
- const text = readFileSync(path, 'utf8');
68
+ // The projection metadata names the dropped fields; scanning it for those names would report
69
+ // the scrub itself as a leak, so it is removed before the marker check.
70
+ const text = readFileSync(path, 'utf8').replace(/"projection"\s*:\s*\{[^{}]*\}/g, '');
81
71
  for (const field of LEDGER_CONTENT_FIELDS) {
82
72
  if (text.includes('"' + field + '"')) hits.push({ file: path, marker: field });
83
73
  }