@twinsearth/w2m-dsh-plugin 1.9.16 → 1.9.19
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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +21 -0
- package/README.md +3 -3
- package/bin/w2m-joint.mjs +36 -5
- package/bin/w2m-scrub.mjs +78 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/1.9.17/RELEASE-1917.json +20 -0
- package/docs/acceptance/1.9.19/RELEASE-1919.json +21 -0
- package/docs/acceptance/2.0.0-beta.1/RELEASE-200B1.json +23 -0
- package/docs/acceptance-ops/2026-10-28/AG-SMOKE.json +24 -0
- package/docs/acceptance-ops/2026-10-28/G0-3-106.53.197.47.json +35 -0
- package/docs/acceptance-ops/2026-10-28/G0-3-202.182.123.154.json +35 -0
- package/docs/acceptance-ops/2026-10-28/G0-3-43.142.74.71.json +35 -0
- package/docs/acceptance-ops/2026-10-28/R1-OPERATOR-TOKEN.json +19 -0
- package/docs/acceptance-ops/2026-10-28/R3-CAPABILITIES.json +51 -0
- package/docs/acceptance-ops/2026-10-28/R8-LEDGER-JOURNAL.json +21 -0
- package/lib/client.js +474 -368
- package/package.json +4 -3
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +6 -1
- package/src/agent/sinks/dir-sink.mjs +59 -13
- package/src/plugin/fleet-chat-route.mjs +7 -1
- package/src/plugin/joint-dev/audit-file.mjs +70 -37
- package/src/plugin/joint-dev/merge-engine.mjs +102 -0
- package/src/plugin/joint-dev/plan-store.mjs +117 -32
- package/src/plugin/joint-dev/relay-dispatch.mjs +171 -0
- package/src/plugin/operator-key.mjs +73 -0
- package/src/plugin/operator-token-route.mjs +71 -0
- package/src/plugin/tools-joint.mjs +97 -0
- package/src/plugin/tools.mjs +8 -3
- package/src/relay/fleet-messages.mjs +22 -0
- package/src/relay/offer-journal.mjs +71 -0
- package/src/relay/p0-projection.mjs +10 -0
- package/src/relay/report.mjs +40 -2
- package/src/relay/scrub.mjs +94 -81
- package/src/relay/server.mjs +2043 -2038
- package/src/relay/state.mjs +3351 -3300
- package/src/util/artifact-ref.mjs +88 -0
- package/src/util/redline-core.mjs +2 -1
package/src/plugin/tools.mjs
CHANGED
|
@@ -191,6 +191,8 @@ import {
|
|
|
191
191
|
import { registerChatRoutes } from './fleet-chat-route.mjs';
|
|
192
192
|
import { registerExecCapabilityRoutes } from './exec-capability-route.mjs';
|
|
193
193
|
import { registerPluginUpdateRoutes } from './update-route.mjs';
|
|
194
|
+
import { registerOperatorTokenRoute } from './operator-token-route.mjs';
|
|
195
|
+
import { registerJointTools } from './tools-joint.mjs';
|
|
194
196
|
import { fingerprintOf, readOperatorToken } from './operator-token.mjs';
|
|
195
197
|
import { registerCaptureRoutes } from './capture-route.mjs';
|
|
196
198
|
import { defaultExecCapabilityFile } from '../util/exec-capability.mjs';
|
|
@@ -284,7 +286,7 @@ const MAX_STAGES = 16;
|
|
|
284
286
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
285
287
|
* overrides it at runtime for tests and packagers.
|
|
286
288
|
*/
|
|
287
|
-
const PLUGIN_VERSION = '1.9.
|
|
289
|
+
const PLUGIN_VERSION = '1.9.19';
|
|
288
290
|
|
|
289
291
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
290
292
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -3583,6 +3585,7 @@ export async function apply(ctx, config = {}) {
|
|
|
3583
3585
|
// v1.6.8: the plugin panel's "更新" button: GET checks npm latest, POST installs through the
|
|
3584
3586
|
// verified updater (SHA-256 checked, old package backed up, restart required afterwards).
|
|
3585
3587
|
const _updateRoutes = registerPluginUpdateRoutes(ctx, { profileDir: () => cfg.profileDir });
|
|
3588
|
+
const _operatorTokenRoute = registerOperatorTokenRoute(ctx, { configuredToken: () => cfg.operatorToken });
|
|
3586
3589
|
|
|
3587
3590
|
// v1.7.1: host-side screenshot + clipboard fallbacks. The DSH desktop shell does not grant the
|
|
3588
3591
|
// renderer getDisplayMedia/navigator.clipboard, so the chat panel needs a host route that does not
|
|
@@ -3625,6 +3628,8 @@ export async function apply(ctx, config = {}) {
|
|
|
3625
3628
|
// -------------------------------------------------------------------------------------------
|
|
3626
3629
|
// 1. w2m_device - read-only
|
|
3627
3630
|
// -------------------------------------------------------------------------------------------
|
|
3631
|
+
registerJointTools(ctx, { defineTool, stateDir: cfg.stateDir, machineName: cfg.machineName, output: jsonOutput });
|
|
3632
|
+
|
|
3628
3633
|
ctx.tools.register(defineTool({
|
|
3629
3634
|
name: 'w2m_devices',
|
|
3630
3635
|
description:
|
|
@@ -4895,8 +4900,8 @@ const result = await request({
|
|
|
4895
4900
|
http_status: probe.status,
|
|
4896
4901
|
protocol_version: health.protocol_version ?? null,
|
|
4897
4902
|
error: probe.ok ? null : (probe.error?.code ?? `HTTP ${probe.status}`),
|
|
4898
|
-
relay_id: health.relay_id ?? null,
|
|
4899
|
-
relay_version: health.relay_version ?? health.version ?? null,
|
|
4903
|
+
relay_id: health.relay_id ?? null,
|
|
4904
|
+
relay_version: health.relay_version ?? health.version ?? null,
|
|
4900
4905
|
features: Array.isArray(health.features) ? health.features : null,
|
|
4901
4906
|
uptime_ms: typeof health.uptime_ms === 'number' ? health.uptime_ms : null,
|
|
4902
4907
|
started_at: health.started_at ?? null,
|
|
@@ -101,6 +101,10 @@ export function createFleetMessages(options = {}) {
|
|
|
101
101
|
if (sessionId === '') return { ok: false, code: 'BAD_SESSION' };
|
|
102
102
|
const machineId = typeof input?.machine_id === 'string' ? input.machine_id.trim() : '';
|
|
103
103
|
if (machineId === '') return { ok: false, code: 'BAD_MACHINE' };
|
|
104
|
+
// v1.9.18: chat carries text and slash-command text only; images never travel over the relay.
|
|
105
|
+
const MEDIA_FIELDS = ['image', 'image_url', 'image_base64', 'image_bytes', 'picture', 'photo', 'thumb', 'thumbnail', 'attachment', 'attachments', 'file', 'files', 'data', 'base64', 'blob', 'binary', 'buffer', 'media', 'sticker_image', 'sticker_data', 'gif', 'png', 'jpeg', 'webp'];
|
|
106
|
+
const mediaField = Object.keys(input ?? {}).find((key) => MEDIA_FIELDS.includes(key.toLowerCase()));
|
|
107
|
+
if (mediaField !== undefined) return { ok: false, code: 'CHAT_FIELD_FORBIDDEN', detail: { field: mediaField } };
|
|
104
108
|
if (typeof input?.text !== 'string') return { ok: false, code: 'NOT_TEXT' };
|
|
105
109
|
const text = input.text;
|
|
106
110
|
if (text.trim() === '') return { ok: false, code: 'EMPTY_TEXT' };
|
|
@@ -130,6 +134,24 @@ export function createFleetMessages(options = {}) {
|
|
|
130
134
|
enc = { v: 1, alg: 'aes-256-gcm', iv: rawEnc.iv, tag: rawEnc.tag };
|
|
131
135
|
}
|
|
132
136
|
|
|
137
|
+
// v1.9.18: a plain-text message may not smuggle an image payload. Encrypted text is exempt:
|
|
138
|
+
// it is base64 ciphertext by design and the relay cannot (and must not) inspect it.
|
|
139
|
+
if (enc === null) {
|
|
140
|
+
if (/data:(?:image|video|audio)\//i.test(text) || /!\[[^\]]*\]\(/i.test(text)) {
|
|
141
|
+
return { ok: false, code: 'TEXT_IMAGE_FORBIDDEN' };
|
|
142
|
+
}
|
|
143
|
+
const block = text.match(/[A-Za-z0-9+/]{256,}={0,2}/);
|
|
144
|
+
if (block !== null) {
|
|
145
|
+
try {
|
|
146
|
+
const head = Buffer.from(block[0].slice(0, 32), 'base64');
|
|
147
|
+
const magic = head.slice(0, 4).toString('latin1');
|
|
148
|
+
if ((head[0] === 0x89 && head.slice(1, 4).toString('latin1') === 'PNG') || (head[0] === 0xff && head[1] === 0xd8) || magic.startsWith('GIF8') || magic.startsWith('RIFF')) {
|
|
149
|
+
return { ok: false, code: 'TEXT_BINARY_FORBIDDEN' };
|
|
150
|
+
}
|
|
151
|
+
} catch { /* not base64 that matters */ }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
133
155
|
// v1.2.5 idempotency: replay the first message instead of storing the text twice.
|
|
134
156
|
const idempotencyKey = typeof input?.idempotency_key === 'string' && input.idempotency_key.trim() !== ''
|
|
135
157
|
? input.idempotency_key.trim().slice(0, 64)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offer journal (v2 R8): the command an in-flight task will be offered with lives HERE, not in the
|
|
3
|
+
* ledger.
|
|
4
|
+
*
|
|
5
|
+
* The ledger must never carry `command_argv` (it is the relay's durable, copyable record), yet a
|
|
6
|
+
* relay restart still needs the command to re-offer unclaimed work. The journal is that narrow,
|
|
7
|
+
* short-lived control-plane store: one 0600 file per task, written before the task is announced,
|
|
8
|
+
* removed when the task settles or is cancelled. It is not an artifact and carries no output.
|
|
9
|
+
*/
|
|
10
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
const journalDir = (stateDir) => join(stateDir, 'offers');
|
|
14
|
+
const journalPath = (stateDir, taskId) => join(journalDir(stateDir), String(taskId).replace(/[^A-Za-z0-9._-]/g, '_') + '.json');
|
|
15
|
+
|
|
16
|
+
export function writeOfferJournal(stateDir, taskId, payload) {
|
|
17
|
+
if (typeof stateDir !== 'string' || stateDir === '' || typeof taskId !== 'string' || taskId === '') {
|
|
18
|
+
return { ok: false, code: 'JOURNAL_INPUT_INVALID' };
|
|
19
|
+
}
|
|
20
|
+
mkdirSync(journalDir(stateDir), { recursive: true });
|
|
21
|
+
const path = journalPath(stateDir, taskId);
|
|
22
|
+
const tmp = path + '.tmp-' + process.pid;
|
|
23
|
+
const text = JSON.stringify({
|
|
24
|
+
v: 1,
|
|
25
|
+
task_id: taskId,
|
|
26
|
+
argv: Array.isArray(payload?.argv) ? payload.argv : [],
|
|
27
|
+
stages: Array.isArray(payload?.stages) ? payload.stages : null,
|
|
28
|
+
reduce_argv: Array.isArray(payload?.reduce_argv) ? payload.reduce_argv : null,
|
|
29
|
+
written_at: new Date().toISOString(),
|
|
30
|
+
});
|
|
31
|
+
const fd = openSync(tmp, 'w', 0o600);
|
|
32
|
+
try {
|
|
33
|
+
writeSync(fd, text);
|
|
34
|
+
fsyncSync(fd);
|
|
35
|
+
} finally {
|
|
36
|
+
closeSync(fd);
|
|
37
|
+
}
|
|
38
|
+
renameSync(tmp, path);
|
|
39
|
+
return { ok: true, path };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function readOfferJournal(stateDir, taskId) {
|
|
43
|
+
const path = journalPath(stateDir, taskId);
|
|
44
|
+
if (!existsSync(path)) return null;
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
47
|
+
if (parsed?.task_id !== taskId) return null;
|
|
48
|
+
return parsed;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function deleteOfferJournal(stateDir, taskId) {
|
|
55
|
+
try { unlinkSync(journalPath(stateDir, taskId)); return true; } catch { return false; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Remove journals whose task no longer exists (settled, cancelled, or lost). */
|
|
59
|
+
export function pruneOfferJournals(stateDir, liveTaskIds) {
|
|
60
|
+
const dir = journalDir(stateDir);
|
|
61
|
+
if (!existsSync(dir)) return 0;
|
|
62
|
+
const live = liveTaskIds instanceof Set ? liveTaskIds : new Set(liveTaskIds ?? []);
|
|
63
|
+
let removed = 0;
|
|
64
|
+
for (const name of readdirSync(dir)) {
|
|
65
|
+
if (!name.endsWith('.json')) continue;
|
|
66
|
+
const taskId = name.slice(0, -5);
|
|
67
|
+
if (live.has(taskId)) continue;
|
|
68
|
+
try { unlinkSync(join(dir, name)); removed += 1; } catch { /* best effort */ }
|
|
69
|
+
}
|
|
70
|
+
return removed;
|
|
71
|
+
}
|
|
@@ -81,6 +81,16 @@ export function projectLedgerLine(line) {
|
|
|
81
81
|
try { record = JSON.parse(line); } catch { return { changed: false, line }; }
|
|
82
82
|
// Real ledger lines nest the envelope one level down: {type:'result.stored', result:{envelope:{...}}}.
|
|
83
83
|
// Handle that shape first, then the flat result, then a bare envelope record.
|
|
84
|
+
// v2 R8: a legacy task record still carries the command itself. Project it to hash + length,
|
|
85
|
+
// so the scrub can bring old ledgers in line with "persist only metadata".
|
|
86
|
+
if (record?.type === 'task.created' && record.task && typeof record.task === 'object') {
|
|
87
|
+
const task = record.task;
|
|
88
|
+
if (!Array.isArray(task.command_argv)) return { changed: false, line };
|
|
89
|
+
task.command_argv_len = task.command_argv.length;
|
|
90
|
+
delete task.command_argv;
|
|
91
|
+
record.projection = { dropped: ['task.command_argv'], at: new Date().toISOString() };
|
|
92
|
+
return { changed: true, line: JSON.stringify(record) };
|
|
93
|
+
}
|
|
84
94
|
let holder = null;
|
|
85
95
|
if (record?.result && typeof record.result === 'object' && record.result.envelope && typeof record.result.envelope === 'object') holder = { parent: record.result, key: 'envelope' };
|
|
86
96
|
else if (record?.result && typeof record.result === 'object') holder = { parent: record, key: 'result' };
|
package/src/relay/report.mjs
CHANGED
|
@@ -524,6 +524,41 @@ export function aggregate(task, resultRecords = [], opts = {}) {
|
|
|
524
524
|
}
|
|
525
525
|
|
|
526
526
|
/** Convenience wrapper: pull the task + its results out of a RabbitState. */
|
|
527
|
+
/** S3: a read-only progress view. phase/progress are live-only (null after a restart). */
|
|
528
|
+
export function taskProgress(state, taskId, opts = {}) {
|
|
529
|
+
const task = state.getTask(taskId);
|
|
530
|
+
if (!task) return null;
|
|
531
|
+
const aggregate = aggregateTask(state, taskId, opts);
|
|
532
|
+
const summary = state.taskSummary(task);
|
|
533
|
+
const machines = aggregate.machines.map((machine) => {
|
|
534
|
+
const lease = task.leases.get(machine.machine_id) ?? null;
|
|
535
|
+
return {
|
|
536
|
+
machine_id: machine.machine_id,
|
|
537
|
+
lease_state: machine.lease_state,
|
|
538
|
+
outcome: machine.outcome,
|
|
539
|
+
exit_code: machine.exit_code ?? null,
|
|
540
|
+
phase: lease?.phase ?? null,
|
|
541
|
+
progress: typeof lease?.progress === 'number' ? lease.progress : null,
|
|
542
|
+
last_heartbeat_at: Number.isFinite(lease?.last_heartbeat_ms) ? rfc3339(lease.last_heartbeat_ms) : null,
|
|
543
|
+
lease_until: Number.isFinite(lease?.lease_until_ms) ? rfc3339(lease.lease_until_ms) : null,
|
|
544
|
+
};
|
|
545
|
+
});
|
|
546
|
+
return {
|
|
547
|
+
task_id: taskId,
|
|
548
|
+
mode: task.mode,
|
|
549
|
+
plan_sha256: task.plan_sha256 ?? null,
|
|
550
|
+
approval_sha256: task.approval_sha256 ?? null,
|
|
551
|
+
step_id: task.step_id ?? null,
|
|
552
|
+
command_hash: aggregate.expected?.command_hash ?? task.command_hash ?? null,
|
|
553
|
+
command_argv_len: Array.isArray(task.command_argv) ? task.command_argv.length : 0,
|
|
554
|
+
status: summary.status,
|
|
555
|
+
verdict: aggregate.status,
|
|
556
|
+
terminal: aggregate.status !== 'pending',
|
|
557
|
+
counts: aggregate.counts,
|
|
558
|
+
waiting_on: summary.waiting_on,
|
|
559
|
+
machines,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
527
562
|
export function aggregateTask(state, taskId, opts = {}) {
|
|
528
563
|
const task = state.getTask(taskId);
|
|
529
564
|
if (!task) throw new ProtocolError('NOT_FOUND', 'unknown task_id', { task_id: taskId });
|
|
@@ -614,10 +649,13 @@ export function renderReportMarkdown(agg, task = null) {
|
|
|
614
649
|
L.push('');
|
|
615
650
|
}
|
|
616
651
|
if (task) {
|
|
617
|
-
|
|
652
|
+
// R8: the markdown report is readable by any paired device token, so it must never carry the
|
|
653
|
+
// command itself. The hash is the command identity; the length is the only shape we expose.
|
|
654
|
+
L.push('## 命令指纹');
|
|
618
655
|
L.push('');
|
|
619
656
|
L.push('```');
|
|
620
|
-
L.push(
|
|
657
|
+
L.push('command_hash: ' + (typeof task.command_hash === 'string' ? task.command_hash : '-'));
|
|
658
|
+
L.push('command_argv_len: ' + (Array.isArray(task.command_argv) ? task.command_argv.length : '-'));
|
|
621
659
|
L.push('```');
|
|
622
660
|
L.push('');
|
|
623
661
|
}
|
package/src/relay/scrub.mjs
CHANGED
|
@@ -1,81 +1,94 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* relay 状态清理(P0):旧 blob 清除 + ledger 内容投影重建。
|
|
3
|
-
* 只处理已落盘的数据,不联网;dry-run 先报清单,apply 先备份再原子替换。
|
|
4
|
-
*/
|
|
5
|
-
import {
|
|
6
|
-
copyFileSync, existsSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync,
|
|
7
|
-
} from 'node:fs';
|
|
8
|
-
import { createHash } from 'node:crypto';
|
|
9
|
-
import { join } from 'node:path';
|
|
10
|
-
|
|
11
|
-
import { projectLedgerLine } from './p0-projection.mjs';
|
|
12
|
-
|
|
13
|
-
export const LEDGER_CONTENT_FIELDS = ['stdout_head', 'stdout_tail', 'stderr_head', 'stderr_tail', 'command_argv', 'pipeline_hash_material', 'changed_files'];
|
|
14
|
-
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
/** 清理 ledger:默认 dry-run;apply 时备份 .bak-<ts> 后原子替换。 */
|
|
18
|
-
export function scrubLedger(ledgerPath, { dryRun = true, now = Date.now() } = {}) {
|
|
19
|
-
if (!existsSync(ledgerPath)) return { ok: true, exists: false, changed: 0, lines: 0, backup: null };
|
|
20
|
-
const text = readFileSync(ledgerPath, 'utf8');
|
|
21
|
-
const lines = text.split('\n').filter((line) => line.trim() !== '');
|
|
22
|
-
let changed = 0;
|
|
23
|
-
const rewritten = lines.map((line) => {
|
|
24
|
-
const projected = projectLedgerLine(line);
|
|
25
|
-
if (projected.changed) changed += 1;
|
|
26
|
-
return projected.line;
|
|
27
|
-
});
|
|
28
|
-
if (dryRun || changed === 0) return { ok: true, exists: true, changed, lines: lines.length, backup: null, dryRun };
|
|
29
|
-
const backup = ledgerPath + '.bak-' + now;
|
|
30
|
-
copyFileSync(ledgerPath, backup);
|
|
31
|
-
const tmp = ledgerPath + '.tmp-' + process.pid;
|
|
32
|
-
writeFileSync(tmp, rewritten.join('\n') + '\n', 'utf8');
|
|
33
|
-
renameSync(tmp, ledgerPath);
|
|
34
|
-
return { ok: true, exists: true, changed, lines: lines.length, backup, dryRun: false };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** 清点/删除 relay blob
|
|
38
|
-
export function scrubBlobs(blobDir, { dryRun = true, now = Date.now() } = {}) {
|
|
39
|
-
if (!existsSync(blobDir)) return { ok: true, exists: false, files: [], bytes: 0, deleted: 0, manifest: null };
|
|
40
|
-
const files = [];
|
|
41
|
-
let bytes = 0;
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
for (const name of readdirSync(dir)) {
|
|
62
|
-
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
1
|
+
/**
|
|
2
|
+
* relay 状态清理(P0):旧 blob 清除 + ledger 内容投影重建。
|
|
3
|
+
* 只处理已落盘的数据,不联网;dry-run 先报清单,apply 先备份再原子替换。
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
copyFileSync, existsSync, readdirSync, readFileSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync,
|
|
7
|
+
} from 'node:fs';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { projectLedgerLine } from './p0-projection.mjs';
|
|
12
|
+
|
|
13
|
+
export const LEDGER_CONTENT_FIELDS = ['stdout_head', 'stdout_tail', 'stderr_head', 'stderr_tail', 'command_argv', 'pipeline_hash_material', 'changed_files'];
|
|
14
|
+
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
/** 清理 ledger:默认 dry-run;apply 时备份 .bak-<ts> 后原子替换。 */
|
|
18
|
+
export function scrubLedger(ledgerPath, { dryRun = true, now = Date.now() } = {}) {
|
|
19
|
+
if (!existsSync(ledgerPath)) return { ok: true, exists: false, changed: 0, lines: 0, backup: null };
|
|
20
|
+
const text = readFileSync(ledgerPath, 'utf8');
|
|
21
|
+
const lines = text.split('\n').filter((line) => line.trim() !== '');
|
|
22
|
+
let changed = 0;
|
|
23
|
+
const rewritten = lines.map((line) => {
|
|
24
|
+
const projected = projectLedgerLine(line);
|
|
25
|
+
if (projected.changed) changed += 1;
|
|
26
|
+
return projected.line;
|
|
27
|
+
});
|
|
28
|
+
if (dryRun || changed === 0) return { ok: true, exists: true, changed, lines: lines.length, backup: null, dryRun };
|
|
29
|
+
const backup = ledgerPath + '.bak-' + now;
|
|
30
|
+
copyFileSync(ledgerPath, backup);
|
|
31
|
+
const tmp = ledgerPath + '.tmp-' + process.pid;
|
|
32
|
+
writeFileSync(tmp, rewritten.join('\n') + '\n', 'utf8');
|
|
33
|
+
renameSync(tmp, ledgerPath);
|
|
34
|
+
return { ok: true, exists: true, changed, lines: lines.length, backup, dryRun: false };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 清点/删除 relay blob 目录(内容寻址;真实布局是 blobs/<aa>/<sha>,递归处理)。 */
|
|
38
|
+
export function scrubBlobs(blobDir, { dryRun = true, now = Date.now() } = {}) {
|
|
39
|
+
if (!existsSync(blobDir)) return { ok: true, exists: false, files: [], bytes: 0, deleted: 0, manifest: null };
|
|
40
|
+
const files = [];
|
|
41
|
+
let bytes = 0;
|
|
42
|
+
const walk = (dir, rel) => {
|
|
43
|
+
for (const name of readdirSync(dir)) {
|
|
44
|
+
const path = join(dir, name);
|
|
45
|
+
const stat = statSync(path);
|
|
46
|
+
const relPath = rel === '' ? name : rel + '/' + name;
|
|
47
|
+
if (stat.isDirectory()) { walk(path, relPath); continue; }
|
|
48
|
+
files.push(relPath);
|
|
49
|
+
bytes += stat.size;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
walk(blobDir, '');
|
|
53
|
+
const manifest = {
|
|
54
|
+
at: new Date(now).toISOString(),
|
|
55
|
+
files: files.map((name) => ({ name, sha256: sha256(readFileSync(join(blobDir, name.split('/').join('/')))) })),
|
|
56
|
+
};
|
|
57
|
+
if (dryRun) return { ok: true, exists: true, files, bytes, deleted: 0, manifest, dryRun };
|
|
58
|
+
for (const name of files) unlinkSync(join(blobDir, name));
|
|
59
|
+
// Remove the empty sharding directories the files lived in, but never the blob root itself.
|
|
60
|
+
const prune = (dir) => {
|
|
61
|
+
for (const name of readdirSync(dir)) {
|
|
62
|
+
const path = join(dir, name);
|
|
63
|
+
if (statSync(path).isDirectory()) { prune(path); try { rmdirSync(path); } catch { /* not empty */ } }
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
prune(blobDir);
|
|
67
|
+
return { ok: true, exists: true, files, bytes, deleted: files.length, manifest, dryRun: false };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 验收:扫描目录中是否仍存在内容字段/数据面 marker。 */
|
|
71
|
+
export function scanForContent(rootDir, markers = []) {
|
|
72
|
+
const hits = [];
|
|
73
|
+
const walk = (dir) => {
|
|
74
|
+
for (const name of readdirSync(dir)) {
|
|
75
|
+
// Backups/temporaries are not live state: they hold the bytes the scrub just moved out.
|
|
76
|
+
if (/\.bak-|\.tmp-|\.corrupt-/.test(name)) continue;
|
|
77
|
+
const path = join(dir, name);
|
|
78
|
+
const stat = statSync(path);
|
|
79
|
+
if (stat.isDirectory()) { walk(path); continue; }
|
|
80
|
+
if (stat.size > 32 * 1024 * 1024) continue;
|
|
81
|
+
// The projection metadata names the dropped fields; scanning it for those names would report
|
|
82
|
+
// the scrub itself as a leak, so it is removed before the marker check.
|
|
83
|
+
const text = readFileSync(path, 'utf8').replace(/"projection"\s*:\s*\{[^{}]*\}/g, '');
|
|
84
|
+
for (const field of LEDGER_CONTENT_FIELDS) {
|
|
85
|
+
if (text.includes('"' + field + '"')) hits.push({ file: path, marker: field });
|
|
86
|
+
}
|
|
87
|
+
for (const marker of markers) {
|
|
88
|
+
if (marker !== '' && text.includes(marker)) hits.push({ file: path, marker });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
if (existsSync(rootDir)) walk(rootDir);
|
|
93
|
+
return { ok: hits.length === 0, hits };
|
|
94
|
+
}
|