@twinsearth/w2m-dsh-plugin 1.9.15 → 1.9.17

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.
@@ -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' };
@@ -614,10 +614,13 @@ export function renderReportMarkdown(agg, task = null) {
614
614
  L.push('');
615
615
  }
616
616
  if (task) {
617
- L.push('## 原始命令');
617
+ // R8: the markdown report is readable by any paired device token, so it must never carry the
618
+ // command itself. The hash is the command identity; the length is the only shape we expose.
619
+ L.push('## 命令指纹');
618
620
  L.push('');
619
621
  L.push('```');
620
- L.push(jcs(task.command_argv));
622
+ L.push('command_hash: ' + (typeof task.command_hash === 'string' ? task.command_hash : '-'));
623
+ L.push('command_argv_len: ' + (Array.isArray(task.command_argv) ? task.command_argv.length : '-'));
621
624
  L.push('```');
622
625
  L.push('');
623
626
  }
@@ -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
- for (const name of readdirSync(blobDir)) {
43
- const path = join(blobDir, name);
44
- if (!statSync(path).isFile()) continue;
45
- files.push(name);
46
- bytes += statSync(path).size;
47
- }
48
- const manifest = {
49
- at: new Date(now).toISOString(),
50
- files: files.map((name) => ({ name, sha256: sha256(readFileSync(join(blobDir, name))) })),
51
- };
52
- if (dryRun) return { ok: true, exists: true, files, bytes, deleted: 0, manifest, dryRun };
53
- for (const name of files) unlinkSync(join(blobDir, name));
54
- return { ok: true, exists: true, files, bytes, deleted: files.length, manifest, dryRun: false };
55
- }
56
-
57
- /** 验收:扫描目录中是否仍存在内容字段/数据面 marker。 */
58
- export function scanForContent(rootDir, markers = []) {
59
- const hits = [];
60
- const walk = (dir) => {
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;
64
- const path = join(dir, name);
65
- const stat = statSync(path);
66
- if (stat.isDirectory()) { walk(path); continue; }
67
- if (stat.size > 32 * 1024 * 1024) continue;
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, '');
71
- for (const field of LEDGER_CONTENT_FIELDS) {
72
- if (text.includes('"' + field + '"')) hits.push({ file: path, marker: field });
73
- }
74
- for (const marker of markers) {
75
- if (marker !== '' && text.includes(marker)) hits.push({ file: path, marker });
76
- }
77
- }
78
- };
79
- if (existsSync(rootDir)) walk(rootDir);
80
- return { ok: hits.length === 0, hits };
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
+ }
@@ -22,6 +22,17 @@ function readRelayVersion() {
22
22
  return process.env.W2M_RELAY_VERSION ?? 'unknown';
23
23
  }
24
24
  export const RELAY_VERSION = readRelayVersion();
25
+
26
+ /**
27
+ * Capabilities a dispatcher may rely on. A relay that does not advertise a feature is an older
28
+ * relay: joint-dev must refuse rather than assume the field will be honoured.
29
+ */
30
+ export const RELAY_FEATURES = Object.freeze([
31
+ 'target-machines',
32
+ 'result-projection',
33
+ 'guard-v1',
34
+ 'relay-version-v1',
35
+ ]);
25
36
  import fs from 'node:fs';
26
37
  import os from 'node:os';
27
38
  import path from 'node:path';
@@ -1591,7 +1602,9 @@ export class RelayServer {
1591
1602
  rabbit_time: this.state.nowIso(),
1592
1603
  uptime_ms: this.state.nowMs() - this._startedAtMs,
1593
1604
  relay_id: this.relayId,
1594
- version: RELAY_VERSION,
1605
+ version: RELAY_VERSION,
1606
+ relay_version: RELAY_VERSION,
1607
+ features: RELAY_FEATURES,
1595
1608
  started_at: rfc3339(this._startedAtMs),
1596
1609
  effective_scheme: this.effectiveScheme(req),
1597
1610
  base_path: this.basePath,