@twinsearth/w2m-dsh-plugin 1.9.16 → 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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
8
- "version": "1.9.16"
8
+ "version": "1.9.17"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "w2m-fleet",
13
13
  "source": "./",
14
14
  "description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
15
- "version": "1.9.16",
15
+ "version": "1.9.17",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "1.9.16"
29
+ "version": "1.9.17"
30
30
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "w2m-fleet",
3
3
  "description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
4
- "version": "1.9.16",
4
+ "version": "1.9.17",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@
7
7
 
8
8
  All notable changes to this project are documented here.
9
9
 
10
+ ## [1.9.17] - 2026-10-28
11
+
12
+ - 修复左侧栏 GPW 入口只剩小图标:恢复 🌐 GPW 文字(侧栏 label 仍为空,不会重复)。
13
+ - 对话全文搜索高亮加强:命中项绿色文字 + 黄色背景(::highlight(w2m-conversation-search) 与 .w2mSearchMark 双样式),主工作区正文中直接可见;显示 当前/总数,Enter/Shift+Enter 或 ↑/↓ 跳转,Esc 清除。
14
+
15
+ ### [2.0.0-beta.1](pre-release) - 2026-10-28
16
+
17
+ 首个联合开发 beta(S0–S1)。
18
+
19
+ - S0 事实基座:operator token 安全存储(0600 文件 + 指纹状态)、relay /healthz 广告 relay_version/features、客户端能力协商 fail-closed;版本偏斜策略按“三机同版本才允许联合开发、enforce 不开启”执行。
20
+ - S1 选择真实生效:派发前按当前设备名册重校验被选机器(离线 → TARGET_UNAVAILABLE,不换机);派发后按 relay leases 校验,未被点名的机器也执行 → 取消任务并 SELECTION_NOT_HONOURED。
21
+ - 联合开发操作员入口:w2m-joint(plan create/list/get、approve、status、dispatch --dry-run,能力协商接入);联合开发默认关闭,属实验性 beta。
22
+ - 运维工具:w2m-scrub(dry-run/apply,递归清点 blobs/<aa>/<sha>,备份必须外置,二次幂等),用于旧 relay 清淤。
23
+ - beta 发布:仅 GitHub prerelease;npm 不发布本 beta。
24
+
10
25
  ## [1.9.16] - 2026-10-28
11
26
 
12
27
  - G0-2:relay /healthz 新增 relay_version 与 features(target-machines / result-projection / guard-v1 / relay-version-v1);w2m_status 显示这两项,便于核对三台 relay 的实际版本与能力。
package/bin/w2m-joint.mjs CHANGED
@@ -21,9 +21,13 @@ import { readFileSync } from 'node:fs';
21
21
 
22
22
  import { buildControlEnvelopes, buildPlan } from '../src/plugin/joint-dev/dispatch.mjs';
23
23
  import { negotiateFeatures, readRelayCapabilities } from '../src/plugin/relay-capabilities.mjs';
24
+ import { dispatchPlanToRelay } from '../src/plugin/joint-dev/relay-dispatch.mjs';
25
+ import { readOperatorToken } from '../src/plugin/operator-token.mjs';
26
+ import { ensureOperatorKey, signApprovalPayload } from '../src/plugin/operator-key.mjs';
24
27
  import {
25
28
  approvePlan,
26
29
  assertDispatchAllowed,
30
+ consumeApproval,
27
31
  listPlans,
28
32
  loadApproval,
29
33
  loadPlan,
@@ -55,7 +59,7 @@ if (stateDir === null) fail('STATE_DIR_REQUIRED', 'pass --state <dir> (the joint
55
59
  if (command === 'plan' && action === 'create') {
56
60
  if (typeof args.steps !== 'string') fail('STEPS_REQUIRED', 'pass --steps <steps.json>');
57
61
  let steps;
58
- try { steps = JSON.parse(readFileSync(args.steps, 'utf8').replace(/^\\uFEFF/, '')); } catch (error) {
62
+ try { steps = JSON.parse(readFileSync(args.steps, 'utf8').replace(/^\uFEFF/, '')); } catch (error) {
59
63
  fail('STEPS_UNREADABLE', error instanceof Error ? error.message : String(error));
60
64
  }
61
65
  if (!Array.isArray(steps)) fail('STEPS_INVALID', 'steps.json must be a JSON array');
@@ -99,6 +103,10 @@ if (command === 'approve') {
99
103
  const authority = Number.parseInt(String(args.authority ?? ''), 10);
100
104
  if (!Number.isInteger(authority)) fail('AUTHORITY_REQUIRED', 'pass --authority <0..100>');
101
105
  const ttlMin = Number.parseInt(String(args['ttl-min'] ?? '10'), 10);
106
+ // decision 1/2: approvals are signed by the local operator Ed25519 key; the private key never
107
+ // leaves this machine, and only its fingerprint is printed.
108
+ const key = ensureOperatorKey();
109
+ if (key.ok !== true) fail(key.code ?? 'OPERATOR_KEY_MISSING', key.detail ?? null);
102
110
  const approved = approvePlan({
103
111
  stateDir,
104
112
  planSha: args.plan,
@@ -106,9 +114,14 @@ if (command === 'approve') {
106
114
  authority,
107
115
  note: typeof args.note === 'string' ? args.note : '',
108
116
  ttlMs: (Number.isFinite(ttlMin) ? ttlMin : 10) * 60_000,
117
+ signer: (body) => ({
118
+ sig: signApprovalPayload(body, key.privateKey),
119
+ key_id: key.key_id,
120
+ public_key_pem: key.publicKeyPem,
121
+ }),
109
122
  });
110
123
  if (approved.ok !== true) fail(approved.code ?? 'APPROVAL_FAILED', approved.detail ?? null);
111
- print({ ok: true, approval: approved.approval, path: approved.path });
124
+ print({ ok: true, approval: approved.approval, path: approved.path, signed: approved.signed === true, operator_key_id: key.key_id });
112
125
  process.exit(0);
113
126
  }
114
127
 
@@ -117,12 +130,13 @@ if (command === 'status') {
117
130
  const plan = loadPlan({ stateDir, planSha: args.plan });
118
131
  if (plan.ok !== true) fail(plan.code ?? 'PLAN_NOT_FOUND');
119
132
  const approval = loadApproval({ stateDir, planSha: args.plan });
120
- const gate = assertDispatchAllowed({ stateDir, planSha: args.plan });
133
+ const gate = assertDispatchAllowed({ stateDir, planSha: args.plan, requireSignature: args['allow-unsigned-approval'] !== true });
121
134
  print({
122
135
  ok: true,
123
136
  plan_sha256: plan.plan.plan_sha256,
124
137
  steps: plan.plan.steps.map((step) => ({ step_id: step.step_id, machine_id: step.machine_id, command_hash: step.command_hash })),
125
138
  approval: approval.ok ? { approver: approval.approval.approver, authority: approval.approval.authority, expires_at: approval.approval.expires_at } : { state: approval.code },
139
+ approval_strength: gate.strength ?? null,
126
140
  dispatch_allowed: gate.ok === true,
127
141
  blocked_by: gate.ok === true ? null : gate.code,
128
142
  });
@@ -131,10 +145,27 @@ if (command === 'status') {
131
145
 
132
146
  if (command === 'dispatch') {
133
147
  if (typeof args.plan !== 'string') fail('PLAN_REQUIRED', 'pass --plan <sha256>');
134
- const gate = assertDispatchAllowed({ stateDir, planSha: args.plan });
148
+ const key = ensureOperatorKey();
149
+ const trusted = key.ok === true ? [key.key_id] : [];
150
+ const gate = assertDispatchAllowed({ stateDir, planSha: args.plan, requireSignature: args['allow-unsigned-approval'] !== true, trustedOperatorKeyIds: trusted });
135
151
  if (gate.ok !== true) fail(gate.code ?? 'APPROVAL_REQUIRED', 'dispatch refused before anything was sent');
152
+ const relayUrl = typeof args.relay === 'string' ? args.relay : null;
136
153
  if (args['dry-run'] !== true) {
137
- fail('DISPATCH_NOT_WIRED', 'real dispatch is the next slice: it needs the operator token and a relay with target_machines/capability advertisement; use --dry-run to inspect the envelopes');
154
+ if (relayUrl === null) fail('RELAY_REQUIRED', 'real dispatch needs --relay <url>');
155
+ let token = null;
156
+ if (typeof args['token-file'] === 'string') {
157
+ token = readFileSync(args['token-file'], 'utf8').trim();
158
+ } else {
159
+ const secure = readOperatorToken();
160
+ token = secure.configured === true ? secure.token : null;
161
+ }
162
+ if (token === null) fail('OPERATOR_TOKEN_MISSING', 'run w2m-operator set --file <token file>, or pass --token-file');
163
+ // decision 2: one approval authorises exactly one dispatch.
164
+ const consumed = consumeApproval({ stateDir, planSha: args.plan, approvalSha: gate.approval.approval.approval_sha256 });
165
+ if (consumed.ok !== true) fail(consumed.code ?? 'APPROVAL_CONSUME_FAILED', consumed.detail ?? null);
166
+ const sent = await dispatchPlanToRelay({ rabbitUrl: relayUrl, token, plan: gate.plan, approval: gate.approval.approval });
167
+ print({ ...sent, plan_sha256: gate.plan.plan_sha256, relay: relayUrl });
168
+ process.exit(sent.ok === true ? 0 : 1);
138
169
  }
139
170
  let capabilities = null;
140
171
  let negotiation = null;
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Relay state scrub CLI (G0-3).
4
+ *
5
+ * Moves the data-plane residue out of a relay's live state: ledger lines are projected (content
6
+ * fields dropped), the content-addressed blob tree (the real layout is blobs/<aa>/<sha>) is deleted,
7
+ * and the result is scanned for any surviving content marker.
8
+ *
9
+ * Dry-run is the default. `--apply` requires `--backup-dir` OUTSIDE the state directory: the bytes
10
+ * the scrub removes must still exist somewhere the operator controls, or this is deletion, not
11
+ * cleanup with a floor.
12
+ *
13
+ * Usage:
14
+ * node bin/w2m-scrub.mjs --state /var/lib/w2m/rabbit
15
+ * node bin/w2m-scrub.mjs --state /var/lib/w2m/rabbit --apply --backup-dir /root/w2m-backups
16
+ *
17
+ * Exit codes: 0 ok, 1 refusal, 2 usage.
18
+ */
19
+ import { cpSync, existsSync, mkdirSync } from 'node:fs';
20
+ import { join, resolve } from 'node:path';
21
+
22
+ import { scanForContent, scrubBlobs, scrubLedger } from '../src/relay/scrub.mjs';
23
+
24
+ const args = process.argv.slice(2);
25
+ const flag = (name) => { const index = args.indexOf(name); return index >= 0 ? args[index + 1] : undefined; };
26
+ const has = (name) => args.includes(name);
27
+ const print = (value) => process.stdout.write(JSON.stringify(value, null, 2) + '\n');
28
+
29
+ const stateDir = flag('--state');
30
+ if (typeof stateDir !== 'string') {
31
+ print({ ok: false, code: 'STATE_REQUIRED', usage: '--state <dir> [--apply --backup-dir <dir>]' });
32
+ process.exit(2);
33
+ }
34
+ const apply = has('--apply');
35
+ const backupDir = flag('--backup-dir');
36
+ const now = Date.now();
37
+
38
+ if (apply === true) {
39
+ if (typeof backupDir !== 'string' || backupDir === '') {
40
+ print({ ok: false, code: 'BACKUP_DIR_REQUIRED', detail: '--apply requires --backup-dir outside the state directory' });
41
+ process.exit(2);
42
+ }
43
+ if (resolve(backupDir).startsWith(resolve(stateDir) + '/') || resolve(backupDir) === resolve(stateDir)) {
44
+ print({ ok: false, code: 'BACKUP_DIR_INSIDE_STATE', detail: 'the backup must live outside the relay state directory' });
45
+ process.exit(2);
46
+ }
47
+ }
48
+
49
+ const stamp = new Date(now).toISOString().replace(/[:.]/g, '-');
50
+ let backupRoot = null;
51
+ if (apply === true) {
52
+ backupRoot = join(backupDir, 'relay-' + stamp);
53
+ try {
54
+ mkdirSync(backupRoot, { recursive: true });
55
+ for (const name of ['ledger.jsonl', 'blobs', 'results', 'devices.json']) {
56
+ const source = join(stateDir, name);
57
+ if (existsSync(source)) cpSync(source, join(backupRoot, name), { recursive: true });
58
+ }
59
+ } catch (error) {
60
+ print({ ok: false, code: 'BACKUP_FAILED', detail: error instanceof Error ? error.message : String(error) });
61
+ process.exit(1);
62
+ }
63
+ }
64
+
65
+ const ledger = scrubLedger(join(stateDir, 'ledger.jsonl'), { dryRun: apply !== true, now });
66
+ const blobs = scrubBlobs(join(stateDir, 'blobs'), { dryRun: apply !== true, now });
67
+ const scan = apply === true ? scanForContent(stateDir) : { ok: null, hits: [] };
68
+
69
+ print({
70
+ ok: ledger.ok === true && blobs.ok === true && (apply !== true || scan.ok === true),
71
+ apply,
72
+ state: stateDir,
73
+ backup_dir: backupRoot,
74
+ ledger: { exists: ledger.exists, changed: ledger.changed, lines: ledger.lines, backup: ledger.backup },
75
+ blobs: { exists: blobs.exists, files: blobs.files?.length ?? 0, bytes: blobs.bytes, deleted: blobs.deleted },
76
+ scan: { ok: scan.ok, hits: scan.hits?.length ?? 0 },
77
+ });
78
+ process.exit(ledger.ok === true && blobs.ok === true && (apply !== true || scan.ok === true) ? 0 : 1);
@@ -12,6 +12,6 @@
12
12
 
13
13
  [mcp_servers.w2m-fleet]
14
14
  command = "npx"
15
- args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.9.16", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.9.17", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -0,0 +1,20 @@
1
+ {
2
+ "version": "1.9.17",
3
+ "case": "RELEASE-1917",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-28T03:57:30.515Z",
9
+ "executor": "lead",
10
+ "task_id": "pre-release-1917-1793159850517",
11
+ "requirement": "V1.9.17:左侧栏 GPW 文字恢复 + 搜索命中绿色文字/黄色背景",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "sidebar": "🌐 GPW(组件内文字,panellist label 仍为空)",
16
+ "search": "::highlight 与 .w2mSearchMark 双样式:color #0b7a33, background #ffeb3b",
17
+ "tests": "client-bundle 26/26"
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "version": "2.0.0-beta.1",
3
+ "case": "RELEASE-200B1",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-28T01:38:12.535Z",
9
+ "executor": "lead",
10
+ "task_id": "pre-release-200b1-1793151492536",
11
+ "requirement": "v2.0.0-beta.1(S0–S1):版本/能力协商、operator token 安全存储、选中真实生效;联合开发默认关闭,beta 不含 npm",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "s0_token": "w2m-operator set/status/clear, 0600 file, fingerprint only (v1.9.15)",
16
+ "s0_capabilities": "relay /healthz relay_version/features; w2m_status exposes them; negotiation fail-closed (v1.9.16)",
17
+ "s1_selection": "pre-dispatch TARGET_UNAVAILABLE; post-dispatch SELECTION_NOT_HONOURED with cancel (v1.9.12)",
18
+ "joint_cli": "w2m-joint plan/approve/status/dispatch --dry-run with capability negotiation (v1.9.13 + current)",
19
+ "scrub_cli": "w2m-scrub recursive blobs, dry-run/apply, external backup, idempotent (current)",
20
+ "open": "G0-3 relay upgrade/cleanup needs SSH window; G0-4 machine version alignment needs operator token; enforce remains off"
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "version": "2.0.0",
3
+ "case": "G0-3-106.53.197.47",
4
+ "kind": "positive",
5
+ "executed_at": "2026-10-28T03:50:51.939Z",
6
+ "executor": "lead",
7
+ "task_id": "g0-3-106.53.197.47-1793159451940",
8
+ "requirement": "relay 升级到 ≥1.9.16,广告 relay_version/features,持久面数据面对象为 0",
9
+ "result": {
10
+ "outcome": "ok",
11
+ "assertions": {
12
+ "method": "SSH (w2m-fleet-deploy) + tarball layout /opt/w2m/app, npm registry tgz sha256 verified",
13
+ "before": {
14
+ "relay_version": "1.9.12",
15
+ "features": null,
16
+ "blobs": 0
17
+ },
18
+ "after": {
19
+ "relay_version": "1.9.16",
20
+ "features": [
21
+ "target-machines",
22
+ "result-projection",
23
+ "guard-v1",
24
+ "relay-version-v1"
25
+ ],
26
+ "blobs": 0,
27
+ "blob_bytes": 0
28
+ },
29
+ "relay_id": "de5d1842d2d28df9",
30
+ "healthz": "http://106.53.197.47:8787/healthz",
31
+ "backup": "/opt/w2m/app.bak-* and /root/w2m-upgrade/<ts>/state on the host",
32
+ "rollback": "script trap restores previous app dir and service; not triggered"
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "version": "2.0.0",
3
+ "case": "G0-3-202.182.123.154",
4
+ "kind": "positive",
5
+ "executed_at": "2026-10-28T03:50:51.939Z",
6
+ "executor": "lead",
7
+ "task_id": "g0-3-202.182.123.154-1793159451941",
8
+ "requirement": "relay 升级到 ≥1.9.16,广告 relay_version/features,持久面数据面对象为 0",
9
+ "result": {
10
+ "outcome": "ok",
11
+ "assertions": {
12
+ "method": "SSH (w2m-fleet-deploy) + tarball layout /opt/w2m/app, npm registry tgz sha256 verified",
13
+ "before": {
14
+ "relay_version": "1.9.12",
15
+ "features": null,
16
+ "blobs": 0
17
+ },
18
+ "after": {
19
+ "relay_version": "1.9.16",
20
+ "features": [
21
+ "target-machines",
22
+ "result-projection",
23
+ "guard-v1",
24
+ "relay-version-v1"
25
+ ],
26
+ "blobs": 0,
27
+ "blob_bytes": 0
28
+ },
29
+ "relay_id": "595a29b256890689",
30
+ "healthz": "http://202.182.123.154:8787/healthz",
31
+ "backup": "/opt/w2m/app.bak-* and /root/w2m-upgrade/<ts>/state on the host",
32
+ "rollback": "script trap restores previous app dir and service; not triggered"
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "version": "2.0.0",
3
+ "case": "G0-3-43.142.74.71",
4
+ "kind": "positive",
5
+ "executed_at": "2026-10-28T03:50:51.939Z",
6
+ "executor": "lead",
7
+ "task_id": "g0-3-43.142.74.71-1793159451940",
8
+ "requirement": "relay 升级到 ≥1.9.16,广告 relay_version/features,持久面数据面对象为 0",
9
+ "result": {
10
+ "outcome": "ok",
11
+ "assertions": {
12
+ "method": "SSH (w2m-fleet-deploy) + tarball layout /opt/w2m/app, npm registry tgz sha256 verified",
13
+ "before": {
14
+ "relay_version": "1.9.12",
15
+ "features": null,
16
+ "blobs": 0
17
+ },
18
+ "after": {
19
+ "relay_version": "1.9.16",
20
+ "features": [
21
+ "target-machines",
22
+ "result-projection",
23
+ "guard-v1",
24
+ "relay-version-v1"
25
+ ],
26
+ "blobs": 0,
27
+ "blob_bytes": 0
28
+ },
29
+ "relay_id": "e34908005df80fe3",
30
+ "healthz": "http://43.142.74.71:8787/healthz",
31
+ "backup": "/opt/w2m/app.bak-* and /root/w2m-upgrade/<ts>/state on the host",
32
+ "rollback": "script trap restores previous app dir and service; not triggered"
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "version": "2.0.0",
3
+ "case": "R1-OPERATOR-TOKEN",
4
+ "kind": "positive",
5
+ "executed_at": "2026-10-28T03:50:51.939Z",
6
+ "executor": "lead",
7
+ "task_id": "r1-token-1793159451943",
8
+ "requirement": "operator token 通过 0600 文件安全写入,状态只显示指纹",
9
+ "result": {
10
+ "outcome": "ok",
11
+ "assertions": {
12
+ "path": "C:/Users/fangw/.w2m/operator-token",
13
+ "source": "file",
14
+ "fingerprint": "209e400801c6",
15
+ "value_handling": "token read from relay unit/state by the operator, written via --file, temp file deleted; never printed",
16
+ "note": "running DSH process must be restarted to pick the file into w2m_run"
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,51 @@
1
+ {
2
+ "version": "2.0.0",
3
+ "case": "R3-CAPABILITIES",
4
+ "kind": "positive",
5
+ "executed_at": "2026-10-28T03:50:51.939Z",
6
+ "executor": "lead",
7
+ "task_id": "r3-capabilities-1793159451943",
8
+ "requirement": "三台 relay 广告 relay_version/features;客户端能力协商通过;旧 relay 仍 fail-closed",
9
+ "result": {
10
+ "outcome": "ok",
11
+ "assertions": {
12
+ "relays": [
13
+ {
14
+ "host": "43.142.74.71",
15
+ "relay_version": "1.9.16",
16
+ "features": [
17
+ "target-machines",
18
+ "result-projection",
19
+ "guard-v1",
20
+ "relay-version-v1"
21
+ ],
22
+ "blobs": 0
23
+ },
24
+ {
25
+ "host": "106.53.197.47",
26
+ "relay_version": "1.9.16",
27
+ "features": [
28
+ "target-machines",
29
+ "result-projection",
30
+ "guard-v1",
31
+ "relay-version-v1"
32
+ ],
33
+ "blobs": 0
34
+ },
35
+ {
36
+ "host": "202.182.123.154",
37
+ "relay_version": "1.9.16",
38
+ "features": [
39
+ "target-machines",
40
+ "result-projection",
41
+ "guard-v1",
42
+ "relay-version-v1"
43
+ ],
44
+ "blobs": 0
45
+ }
46
+ ],
47
+ "client_negotiation": "w2m-joint dispatch --dry-run --relay http://43.142.74.71:8787 -> relay_negotiation.ok=true",
48
+ "fail_closed": "old relay without features still returns RELAY_CAPABILITIES_MISSING (unit test)"
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "version": "2.0.0",
3
+ "case": "R8-LEDGER-JOURNAL",
4
+ "kind": "positive",
5
+ "executed_at": "2026-10-28T03:55:45.117Z",
6
+ "executor": "lead",
7
+ "task_id": "r8-ledger-journal-1793159745118",
8
+ "requirement": "relay ledger 只存 command_hash/command_argv_len;argv 移到独立 offer journal;重启从 journal 恢复;command_hash 由 relay 派生",
9
+ "result": {
10
+ "outcome": "ok",
11
+ "assertions": {
12
+ "ledger": "task.created 行不含 \"command_argv\",含 command_argv_len(e2e 断言)",
13
+ "journal": "<stateDir>/offers/<task_id>.json 0600,字段 argv/stages/reduce_argv,写入先于任务通告",
14
+ "restart": "同一 stateDir 重启后 getTask(task_id).command_argv 从 journal 恢复,且 offer_unavailable 不为 true",
15
+ "hash": "调用方传入不匹配的 command_hash -> 400 BAD_REQUEST(relay 自行派生)",
16
+ "journal_lifecycle": "cancelled 删除;启动时 prune 掉无任务的 journal",
17
+ "tests": "test/relay-offer-journal.test.mjs 3/3;relay/joint/recovery/p2p 22 套件 384 项 383 pass / 1 skip / 0 fail;e2e 11/11",
18
+ "known_limit": "journal 不是数据面对象,但仍含指令 argv(控制面允许);扫描器按字段名不命中,需文档声明其归属"
19
+ }
20
+ }
21
+ }