@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.6
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/CHANGELOG.md +1288 -0
- package/LICENSE +21 -0
- package/PROTOCOL-v0.1.2.md +165 -0
- package/PROTOCOL-v0.3.0.md +454 -0
- package/PROTOCOL-v0.3.9.md +207 -0
- package/PROTOCOL-v0.4.0.md +255 -0
- package/PROTOCOL.md +339 -0
- package/README.md +665 -2
- package/bin/w2m-localside.mjs +303 -0
- package/bin/w2m-rabbit.mjs +396 -0
- package/bin/w2m-stun.mjs +231 -0
- package/cordis.patch.yml +94 -0
- package/deploy/caddy/Caddyfile.example +118 -0
- package/deploy/docker/Dockerfile +144 -0
- package/deploy/docker/README.md +338 -0
- package/deploy/docker/docker-compose.yml +179 -0
- package/deploy/nginx/w2m.conf.example +196 -0
- package/deploy/shared-server/README.md +118 -0
- package/deploy/shared-server/install.sh +255 -0
- package/deploy/systemd/w2m-rabbit.service +150 -0
- package/deploy/systemd/w2m-stun.service +69 -0
- package/deploy/windows/README.md +115 -0
- package/deploy/windows/install-service.ps1 +195 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
- package/deploy/windows/winsw/w2m-localside.xml +50 -0
- package/docs/DEPLOY.md +902 -0
- package/docs/LINT-AUDIT.md +492 -0
- package/docs/PIPELINE-DESIGN.md +95 -0
- package/docs/TROUBLESHOOTING.md +560 -0
- package/docs/UI-CARDS.md +81 -0
- package/lib/index.js +13 -0
- package/lib/tools.js +11 -0
- package/package.json +133 -4
- package/src/agent/agent.mjs +3317 -0
- package/src/agent/allowed-commands.mjs +116 -0
- package/src/agent/caps.mjs +437 -0
- package/src/agent/exec.mjs +356 -0
- package/src/agent/git.mjs +616 -0
- package/src/agent/identity.mjs +253 -0
- package/src/agent/p2p-node.mjs +1243 -0
- package/src/agent/p2p.mjs +605 -0
- package/src/agent/resolve.mjs +148 -0
- package/src/agent/spool.mjs +252 -0
- package/src/agent/stun.mjs +397 -0
- package/src/agent/url.mjs +127 -0
- package/src/plugin/auto-update.mjs +574 -0
- package/src/plugin/schedule.mjs +452 -0
- package/src/plugin/shared-config.mjs +264 -0
- package/src/plugin/tools.mjs +3891 -0
- package/src/plugin/update-install.mjs +764 -0
- package/src/plugin/update-source.mjs +686 -0
- package/src/relay/metrics.mjs +284 -0
- package/src/relay/peers.mjs +180 -0
- package/src/relay/persistence.mjs +207 -0
- package/src/relay/report.mjs +591 -0
- package/src/relay/server.mjs +1187 -0
- package/src/relay/state.mjs +2004 -0
- package/src/relay/stun-server.mjs +733 -0
- package/src/signing.mjs +359 -0
- package/src/util/cli.mjs +70 -0
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W2M Rabbit relay — §6.3 six-state aggregation + markdown/JSON reports.
|
|
3
|
+
*
|
|
4
|
+
* The step order is part of the contract: Step 0 → Step 1 → Step 2 → Step 3.
|
|
5
|
+
* Steps are NEVER merged, because the order changes the verdict.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
COMPARABLE_FIELDS,
|
|
10
|
+
FAILURE_STATUSES,
|
|
11
|
+
ProtocolError,
|
|
12
|
+
PROTOCOL_VERSION,
|
|
13
|
+
jcs,
|
|
14
|
+
rfc3339,
|
|
15
|
+
} from './state.mjs';
|
|
16
|
+
|
|
17
|
+
/** Aggregate statuses this module can return. */
|
|
18
|
+
export const AGGREGATE_STATUSES = Object.freeze([
|
|
19
|
+
// ---- the six verdicts of §6.3 ----
|
|
20
|
+
'consistent',
|
|
21
|
+
'divergent',
|
|
22
|
+
'divergent-platform',
|
|
23
|
+
'failed',
|
|
24
|
+
'partial',
|
|
25
|
+
'unverifiable',
|
|
26
|
+
// ---- added in v0.3.0 ----
|
|
27
|
+
//
|
|
28
|
+
// These three are *verdicts*, not phase flags, which is why they belong in this list rather than
|
|
29
|
+
// beside `pending`. Each answers a question the original six could not express:
|
|
30
|
+
//
|
|
31
|
+
// timeout nobody reported and the work is no longer waiting on anybody. `partial` said "some
|
|
32
|
+
// machines are still out"; `timeout` says "we waited as long as we agreed to wait,
|
|
33
|
+
// and the machines that never answered are not coming back".
|
|
34
|
+
// cancelled the operator stopped it. Distinct from `failed`: nothing went wrong, we chose this.
|
|
35
|
+
// A cancellation must not be reported as a defect, or every intentional stop becomes
|
|
36
|
+
// noise that trains people to ignore the status.
|
|
37
|
+
// degraded every machine reported, some succeeded and some failed. `partial` used to carry
|
|
38
|
+
// this, but `partial` also means "still in flight", so a reader could not tell a
|
|
39
|
+
// finished task in a bad state from one that had not finished. Both are now explicit.
|
|
40
|
+
'timeout',
|
|
41
|
+
'cancelled',
|
|
42
|
+
'degraded',
|
|
43
|
+
// ---- not verdicts: no terminal answer exists yet, or none was ever possible ----
|
|
44
|
+
'refused',
|
|
45
|
+
'pending',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/** Comparable fields that are derived purely from the stdout byte stream. */
|
|
49
|
+
const STDOUT_DERIVED_FIELDS = Object.freeze([
|
|
50
|
+
'stdout_sha256',
|
|
51
|
+
'stdout_bytes',
|
|
52
|
+
'stdout_normalized_sha256',
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
function valueOf(envelope, field) {
|
|
56
|
+
return envelope === null || envelope === undefined ? undefined : envelope[field];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function canon(value) {
|
|
60
|
+
return value === undefined ? '<absent>' : jcs(value);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function truncate(text, max = 160) {
|
|
64
|
+
const s = typeof text === 'string' ? text : String(text);
|
|
65
|
+
return s.length <= max ? s : `${s.slice(0, max - 1)}…`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Warning codes that explain *why* a machine failed, in a sentence a reader can act on.
|
|
70
|
+
*
|
|
71
|
+
* The envelope already carried these codes; nothing downstream read them. A spawn failure was
|
|
72
|
+
* therefore rendered as `status: crashed`, `exit_code: null`, and an empty 说明 column -- the three
|
|
73
|
+
* facts that together say nothing. Measured on macOS: a task whose `argv[0]` was not on PATH came
|
|
74
|
+
* back exactly that way, with `warnings: ["PATH_INVALID"]` sitting unread in the envelope.
|
|
75
|
+
*
|
|
76
|
+
* Only codes whose meaning is unambiguous belong here. Inventing prose for a code we do not
|
|
77
|
+
* understand would be worse than the empty cell, so anything unlisted stays unreported.
|
|
78
|
+
*/
|
|
79
|
+
const FAILURE_WARNING_REASONS = {
|
|
80
|
+
PATH_INVALID: 'the command could not be started: an executable in the argv was not found (PATH_INVALID)',
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Fill in a failed machine's `reasons` from its execution warnings.
|
|
85
|
+
*
|
|
86
|
+
* `reasons` is what the human report renders in the 说明 column, and it was only ever populated by
|
|
87
|
+
* anchor/comparison problems -- so a machine that never started had nothing to say for itself.
|
|
88
|
+
*
|
|
89
|
+
* @param {object} m Machine record being aggregated.
|
|
90
|
+
* @param {string} status The envelope's execution status.
|
|
91
|
+
*/
|
|
92
|
+
function explainExecutionFailure(m, status) {
|
|
93
|
+
if (m.reasons.length > 0) return;
|
|
94
|
+
// `nonzero_exit` is self-explanatory: the command ran and `exit_code` reports what it returned.
|
|
95
|
+
// Only a machine that produced no exit status at all needs the warning spelled out.
|
|
96
|
+
if (status !== 'crashed' && status !== 'timeout') return;
|
|
97
|
+
const warnings = Array.isArray(m.envelope?.warnings) ? m.envelope.warnings : [];
|
|
98
|
+
for (const warning of warnings) {
|
|
99
|
+
const reason = FAILURE_WARNING_REASONS[warning];
|
|
100
|
+
if (reason) {
|
|
101
|
+
m.reasons.push(reason);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* §6.3 six-state aggregation.
|
|
109
|
+
*
|
|
110
|
+
* @param {object} task Rabbit task record (state.mjs `createTask` result)
|
|
111
|
+
* @param {Iterable<object>} resultRecords result records from `RabbitState#results`
|
|
112
|
+
* @param {{nowMs?:number}} [opts]
|
|
113
|
+
* @returns {object} aggregation report data
|
|
114
|
+
*/
|
|
115
|
+
export function aggregate(task, resultRecords = [], opts = {}) {
|
|
116
|
+
if (!task) throw new ProtocolError('NOT_FOUND', 'task is required for aggregation');
|
|
117
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
118
|
+
|
|
119
|
+
const records = [...resultRecords].filter((r) => r?.envelope?.task_id === task.task_id);
|
|
120
|
+
// latest attempt per machine wins
|
|
121
|
+
const byMachine = new Map();
|
|
122
|
+
for (const rec of records) {
|
|
123
|
+
const prev = byMachine.get(rec.machine_id);
|
|
124
|
+
if (!prev || rec.received_at_ms >= prev.received_at_ms) byMachine.set(rec.machine_id, rec);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const expected = {
|
|
128
|
+
base_commit: task.base_commit ?? null,
|
|
129
|
+
base_tree: task.base_tree ?? null,
|
|
130
|
+
command_hash: task.command_hash ?? null,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const machines = [];
|
|
134
|
+
for (const lease of task.leases.values()) {
|
|
135
|
+
const rec = byMachine.get(lease.machine_id) ?? null;
|
|
136
|
+
machines.push({
|
|
137
|
+
machine_id: lease.machine_id,
|
|
138
|
+
machine_name: lease.machine_name ?? null,
|
|
139
|
+
index: lease.index,
|
|
140
|
+
attempt: lease.attempt,
|
|
141
|
+
lease_state: lease.state,
|
|
142
|
+
lease_expired: lease.state === 'expired',
|
|
143
|
+
refused_reason: lease.refusal_reason ?? null,
|
|
144
|
+
gate_refused: lease.state === 'refused',
|
|
145
|
+
// v0.3.3 `broadcast`: a machine that was told the outcome but never handed the command. It has
|
|
146
|
+
// no envelope and never will, so without this marker the anchor step classified it `pending`
|
|
147
|
+
// and the task could never reach a verdict -- a broadcast would hang forever.
|
|
148
|
+
observing: lease.state === 'observing',
|
|
149
|
+
result: rec,
|
|
150
|
+
envelope: rec?.envelope ?? null,
|
|
151
|
+
outcome: null,
|
|
152
|
+
reasons: [],
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
// results from machines with no lease (e.g. takeover / manual upload)
|
|
156
|
+
for (const [machineId, rec] of byMachine) {
|
|
157
|
+
if (machines.some((m) => m.machine_id === machineId)) continue;
|
|
158
|
+
machines.push({
|
|
159
|
+
machine_id: machineId,
|
|
160
|
+
machine_name: rec.envelope.machine_name ?? null,
|
|
161
|
+
index: rec.envelope.index ?? null,
|
|
162
|
+
attempt: rec.attempt,
|
|
163
|
+
lease_state: 'unleased',
|
|
164
|
+
lease_expired: false,
|
|
165
|
+
refused_reason: rec.envelope.refusal_reason ?? null,
|
|
166
|
+
gate_refused: false,
|
|
167
|
+
result: rec,
|
|
168
|
+
envelope: rec.envelope,
|
|
169
|
+
outcome: null,
|
|
170
|
+
reasons: [],
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const steps = [];
|
|
175
|
+
const notes = [];
|
|
176
|
+
|
|
177
|
+
/* -------- Step 0: refusals are booked per machine, they never block others -------- */
|
|
178
|
+
const step0 = { step: 0, name: 'refusal', refused: [], passed: [], observing: [] };
|
|
179
|
+
for (const m of machines) {
|
|
180
|
+
if (m.observing) {
|
|
181
|
+
// Not a participant: it was never asked to run, so it cannot refuse, cannot pass, and must
|
|
182
|
+
// never be counted as agreeing. Recording the outcome is the whole reason it is listed.
|
|
183
|
+
m.outcome = 'observing';
|
|
184
|
+
step0.observing.push(m.machine_id);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const refusedByGate = m.gate_refused;
|
|
188
|
+
const refusedByEnvelope = m.envelope?.status === 'refused';
|
|
189
|
+
if (refusedByGate || refusedByEnvelope) {
|
|
190
|
+
m.outcome = 'refused';
|
|
191
|
+
m.refusal_reason = refusedByGate
|
|
192
|
+
? (m.refused_reason ?? 'REFUSED')
|
|
193
|
+
: (m.envelope.refusal_reason ?? 'REFUSED');
|
|
194
|
+
step0.refused.push({ machine_id: m.machine_id, refusal_reason: m.refusal_reason });
|
|
195
|
+
} else {
|
|
196
|
+
step0.passed.push(m.machine_id);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
steps.push(step0);
|
|
200
|
+
|
|
201
|
+
const participants = machines.filter((m) => m.outcome !== 'refused' && m.outcome !== 'observing');
|
|
202
|
+
|
|
203
|
+
/* -------- Step 1: anchors — any violation refuses aggregation entirely -------- */
|
|
204
|
+
const step1 = { step: 1, name: 'anchors', violations: [], passed: [], pending: [] };
|
|
205
|
+
for (const m of participants) {
|
|
206
|
+
const env = m.envelope;
|
|
207
|
+
if (!env) {
|
|
208
|
+
const expired = m.lease_expired;
|
|
209
|
+
m.outcome = expired ? 'expired' : 'pending';
|
|
210
|
+
step1.pending.push(m.machine_id);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const validation = m.result?.validation ?? { missing: [], problems: [] };
|
|
214
|
+
const missing = [...(validation.missing ?? [])];
|
|
215
|
+
if (m.result?.effective_status === 'unverifiable' && missing.length === 0) {
|
|
216
|
+
m.reasons.push('envelope status = unverifiable');
|
|
217
|
+
}
|
|
218
|
+
if (missing.length > 0) m.reasons.push(`missing required fields: ${missing.join(',')}`);
|
|
219
|
+
if (canon(valueOf(env, 'base_commit')) !== canon(expected.base_commit)) {
|
|
220
|
+
m.reasons.push(`base_commit ${truncate(canon(env.base_commit), 24)} != expected ${truncate(canon(expected.base_commit), 24)}`);
|
|
221
|
+
}
|
|
222
|
+
if (expected.base_tree !== null && canon(valueOf(env, 'pre_tree_fingerprint')) !== canon(expected.base_tree)) {
|
|
223
|
+
m.reasons.push('pre_tree_fingerprint != base_tree');
|
|
224
|
+
}
|
|
225
|
+
if (canon(valueOf(env, 'command_hash')) !== canon(expected.command_hash)) {
|
|
226
|
+
m.reasons.push('command_hash mismatch');
|
|
227
|
+
}
|
|
228
|
+
if (env.status === 'unverifiable' && m.reasons.length === 0) {
|
|
229
|
+
m.reasons.push('envelope status = unverifiable');
|
|
230
|
+
}
|
|
231
|
+
if (m.reasons.length > 0) {
|
|
232
|
+
m.outcome = 'unverifiable';
|
|
233
|
+
step1.violations.push({ machine_id: m.machine_id, reasons: [...m.reasons] });
|
|
234
|
+
} else {
|
|
235
|
+
m.outcome = 'ok-pending-step2';
|
|
236
|
+
step1.passed.push(m.machine_id);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
steps.push(step1);
|
|
240
|
+
|
|
241
|
+
const runnable = participants.filter((m) => m.outcome === 'ok-pending-step2');
|
|
242
|
+
const expiredMachines = participants.filter((m) => m.outcome === 'expired');
|
|
243
|
+
const pendingMachines = participants.filter((m) => m.outcome === 'pending');
|
|
244
|
+
|
|
245
|
+
let status;
|
|
246
|
+
const step2 = { step: 2, name: 'execution', ok: [], failed: [], partial: false, expired: [], pending: [] };
|
|
247
|
+
const step3 = { step: 3, name: 'comparison', differences: [], consistent: false, platform_only: false };
|
|
248
|
+
|
|
249
|
+
if (step1.violations.length > 0) {
|
|
250
|
+
/* §6.3 Step 1: 拒绝汇总 */
|
|
251
|
+
status = 'unverifiable';
|
|
252
|
+
step2.skipped = 'step1_unverifiable';
|
|
253
|
+
step3.skipped = 'step1_unverifiable';
|
|
254
|
+
notes.push('aggregation refused: Step 1 anchor violation (see unverifiable machines)');
|
|
255
|
+
} else if (task.cancelled === true) {
|
|
256
|
+
/* v0.3.0: the operator stopped it. A chosen stop is not a defect. */
|
|
257
|
+
//
|
|
258
|
+
// Checked before `unverifiable`-adjacent outcomes and before any timeout: once someone has
|
|
259
|
+
// deliberately stopped the work, reporting "failed" or "timed out" would describe a decision as
|
|
260
|
+
// a malfunction, and enough of those trains a reader to ignore the status entirely.
|
|
261
|
+
status = 'cancelled';
|
|
262
|
+
step2.skipped = 'task_cancelled';
|
|
263
|
+
step3.skipped = 'task_cancelled';
|
|
264
|
+
notes.push(
|
|
265
|
+
task.cancel_reason ? `cancelled by the operator: ${task.cancel_reason}` : 'cancelled by the operator',
|
|
266
|
+
);
|
|
267
|
+
} else if (runnable.length === 0 && expiredMachines.length === 0 && pendingMachines.length === 0 && step0.refused.length > 0) {
|
|
268
|
+
status = 'refused';
|
|
269
|
+
step2.skipped = 'all_refused';
|
|
270
|
+
step3.skipped = 'all_refused';
|
|
271
|
+
} else if (runnable.length === 0 && expiredMachines.length === 0) {
|
|
272
|
+
/* v0.3.0: distinguish "still waiting" from "waited long enough". */
|
|
273
|
+
//
|
|
274
|
+
// `pending` means the task is live and more envelopes may still arrive. Once the deadline the
|
|
275
|
+
// caller set has passed and machines are still silent, that is a verdict, and calling it
|
|
276
|
+
// `pending` forever leaves a reader unable to tell a stuck task from a slow one.
|
|
277
|
+
const deadline = Number.isFinite(task.deadline_ms) ? task.deadline_ms : 0;
|
|
278
|
+
if (deadline > 0 && nowMs >= deadline) {
|
|
279
|
+
status = 'timeout';
|
|
280
|
+
step2.skipped = 'deadline_passed';
|
|
281
|
+
step3.skipped = 'deadline_passed';
|
|
282
|
+
notes.push(
|
|
283
|
+
`no result envelope by the ${task.timeout_ms}ms deadline (${pendingMachines.length} machine(s) still silent)`,
|
|
284
|
+
);
|
|
285
|
+
} else {
|
|
286
|
+
status = 'pending';
|
|
287
|
+
step2.skipped = 'no_results_yet';
|
|
288
|
+
step3.skipped = 'no_results_yet';
|
|
289
|
+
notes.push('no result envelope has been received yet');
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
for (const m of runnable) {
|
|
293
|
+
const s = m.envelope.status;
|
|
294
|
+
if (s === 'ok') { m.outcome = 'ok'; step2.ok.push(m.machine_id); }
|
|
295
|
+
else {
|
|
296
|
+
// {nonzero_exit,timeout,crashed} plus anything outside the §5.1 enum
|
|
297
|
+
m.outcome = 'failed';
|
|
298
|
+
explainExecutionFailure(m, s);
|
|
299
|
+
step2.failed.push({
|
|
300
|
+
machine_id: m.machine_id,
|
|
301
|
+
status: s,
|
|
302
|
+
exit_code: m.envelope.exit_code ?? null,
|
|
303
|
+
known_failure: FAILURE_STATUSES.includes(s),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
for (const m of expiredMachines) step2.expired.push(m.machine_id);
|
|
308
|
+
for (const m of pendingMachines) step2.pending.push(m.machine_id);
|
|
309
|
+
|
|
310
|
+
if (step2.expired.length > 0) {
|
|
311
|
+
/* §4.3: halt=never → booked and marked partial */
|
|
312
|
+
status = 'partial';
|
|
313
|
+
step2.partial = true;
|
|
314
|
+
notes.push(`lease expired on ${step2.expired.length} machine(s) → partial (§4.3)`);
|
|
315
|
+
step3.skipped = 'lease_expired';
|
|
316
|
+
} else if (step2.pending.length > 0) {
|
|
317
|
+
status = 'partial';
|
|
318
|
+
step2.partial = true;
|
|
319
|
+
notes.push(`waiting for ${step2.pending.length} machine(s) to report`);
|
|
320
|
+
step3.skipped = 'machines_pending';
|
|
321
|
+
} else if (step2.failed.length === 0 && step2.ok.length > 0) {
|
|
322
|
+
/* §6.3 Step 2: 全部 ok → Step 3 */
|
|
323
|
+
const okMachines = runnable.filter((m) => m.outcome === 'ok');
|
|
324
|
+
|
|
325
|
+
// Compare **within a shard**, not across the whole task.
|
|
326
|
+
//
|
|
327
|
+
// `index` is a comparable field, and `split` assigns it per machine by design -- so comparing
|
|
328
|
+
// every machine against every other made `index` differ on any successful fan-out, and every
|
|
329
|
+
// split task was reported `divergent`. That is a false alarm on the normal case: `divergent`
|
|
330
|
+
// tells an operator the machines disagreed, when in fact they did exactly what was asked.
|
|
331
|
+
//
|
|
332
|
+
// Grouping by `index` fixes it without special-casing the field: machines meant to produce the
|
|
333
|
+
// same output are compared with each other, and machines given different slices are not
|
|
334
|
+
// compared at all. For `replicate` every machine has index 0, so there is a single group and
|
|
335
|
+
// the behaviour is exactly as before -- which is why the existing 140 relay tests still pass
|
|
336
|
+
// unchanged.
|
|
337
|
+
const shards = new Map();
|
|
338
|
+
for (const m of okMachines) {
|
|
339
|
+
const key = m.index ?? 0;
|
|
340
|
+
if (!shards.has(key)) shards.set(key, []);
|
|
341
|
+
shards.get(key).push(m);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const comparable = {};
|
|
345
|
+
for (const field of COMPARABLE_FIELDS) {
|
|
346
|
+
comparable[field] = {};
|
|
347
|
+
for (const m of okMachines) {
|
|
348
|
+
const v = valueOf(m.envelope, field);
|
|
349
|
+
if (v !== undefined) comparable[field][m.machine_id] = v;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Fields that differ within some shard, with the shard that disagreed.
|
|
355
|
+
*
|
|
356
|
+
* `index` is reported with the shard so a genuine mixed-mode divergence ("shard 0 agreed,
|
|
357
|
+
* shard 1 did not") stays unambiguous.
|
|
358
|
+
*/
|
|
359
|
+
const differences = [];
|
|
360
|
+
for (const [shardIndex, members] of shards) {
|
|
361
|
+
if (members.length < 2) continue; // one machine cannot disagree with itself
|
|
362
|
+
for (const field of COMPARABLE_FIELDS) {
|
|
363
|
+
const values = members.map((m) => canon(valueOf(m.envelope, field)));
|
|
364
|
+
if (values.some((v) => v !== values[0])) {
|
|
365
|
+
differences.push({
|
|
366
|
+
field,
|
|
367
|
+
index: shardIndex,
|
|
368
|
+
values: Object.fromEntries(members.map((m) => [m.machine_id, valueOf(m.envelope, field)])),
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
step3.comparable = comparable;
|
|
374
|
+
step3.differences = differences;
|
|
375
|
+
|
|
376
|
+
/** Metadata drift within any shard, for the `divergent-platform` branch. */
|
|
377
|
+
const metaDiffers = (field) => {
|
|
378
|
+
for (const members of shards.values()) {
|
|
379
|
+
if (members.length < 2) continue;
|
|
380
|
+
const vals = members.map((m) => canon(valueOf(m.envelope, field)));
|
|
381
|
+
if (vals.some((v) => v !== vals[0])) return true;
|
|
382
|
+
}
|
|
383
|
+
return false;
|
|
384
|
+
};
|
|
385
|
+
const platformMetaDiffers = metaDiffers('toolchain') || metaDiffers('platform');
|
|
386
|
+
|
|
387
|
+
if (differences.length === 0) {
|
|
388
|
+
status = 'consistent';
|
|
389
|
+
step3.consistent = true;
|
|
390
|
+
} else if (platformMetaDiffers && differences.every((d) => STDOUT_DERIVED_FIELDS.includes(d.field))) {
|
|
391
|
+
/* §6.3 Step 3: expected — only toolchain/platform differ and the drift is stdout-only */
|
|
392
|
+
status = 'divergent-platform';
|
|
393
|
+
step3.platform_only = true;
|
|
394
|
+
notes.push('divergence is confined to stdout bytes across differing toolchain/platform (expected, no alert)');
|
|
395
|
+
} else {
|
|
396
|
+
status = 'divergent';
|
|
397
|
+
notes.push(`divergent fields: ${differences.map((d) => d.field).join(', ')}`);
|
|
398
|
+
}
|
|
399
|
+
} else if (step2.ok.length > 0) {
|
|
400
|
+
/* v0.3.0 §6.3 Step 2: 部分 ok、部分失败 → degraded */
|
|
401
|
+
//
|
|
402
|
+
// This used to be `partial`, which was wrong in a way that mattered: `partial` is also the
|
|
403
|
+
// answer while machines are still out, so "finished, and half of it failed" was
|
|
404
|
+
// indistinguishable from "still running" in the one field a reader looks at first. Every
|
|
405
|
+
// machine here has reported, so the task is over and the verdict is `degraded` -- some of the
|
|
406
|
+
// work succeeded, the rest did not, and nothing further is coming.
|
|
407
|
+
status = 'degraded';
|
|
408
|
+
step2.partial = true;
|
|
409
|
+
notes.push(`${step2.ok.length} machine(s) succeeded and ${step2.failed.length} failed`);
|
|
410
|
+
} else {
|
|
411
|
+
/* §6.3 Step 2: 全部失败 → failed */
|
|
412
|
+
status = 'failed';
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
steps.push(step2);
|
|
417
|
+
steps.push(step3);
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
protocol_version: PROTOCOL_VERSION,
|
|
421
|
+
task_id: task.task_id,
|
|
422
|
+
status,
|
|
423
|
+
computed_at: rfc3339(nowMs),
|
|
424
|
+
mode: task.mode,
|
|
425
|
+
index_total: task.index_total,
|
|
426
|
+
halt: task.halt,
|
|
427
|
+
write: task.write,
|
|
428
|
+
created_at: rfc3339(task.created_at_ms),
|
|
429
|
+
created_by: task.created_by ?? null,
|
|
430
|
+
cancelled: task.cancelled === true,
|
|
431
|
+
expected,
|
|
432
|
+
machines: machines.map((m) => ({
|
|
433
|
+
machine_id: m.machine_id,
|
|
434
|
+
machine_name: m.machine_name,
|
|
435
|
+
index: m.index,
|
|
436
|
+
attempt: m.attempt,
|
|
437
|
+
lease_state: m.lease_state,
|
|
438
|
+
outcome: m.outcome,
|
|
439
|
+
status: m.envelope?.status ?? null,
|
|
440
|
+
refusal_reason: m.refusal_reason ?? null,
|
|
441
|
+
exit_code: m.envelope?.exit_code ?? null,
|
|
442
|
+
/* v0.4.0: how this machine's envelope travelled, straight off the stored envelope.
|
|
443
|
+
*
|
|
444
|
+
* `null` means "the envelope did not say" -- every v0.3.9 result, and the honest answer for a
|
|
445
|
+
* relay that simply does not know. These two fields are REPORTED, never judged: they are not
|
|
446
|
+
* in COMPARABLE_FIELDS, so a machine that answered over P2P and one that answered over the
|
|
447
|
+
* relay still produce the same verdict. A path is not a result. */
|
|
448
|
+
transport: m.envelope?.transport ?? null,
|
|
449
|
+
p2p: m.envelope?.p2p ?? null,
|
|
450
|
+
reasons: m.reasons,
|
|
451
|
+
})),
|
|
452
|
+
steps,
|
|
453
|
+
differences: step3.differences ?? [],
|
|
454
|
+
notes,
|
|
455
|
+
counts: {
|
|
456
|
+
refused: step0.refused.length,
|
|
457
|
+
unverifiable: step1.violations.length,
|
|
458
|
+
ok: step2.ok?.length ?? 0,
|
|
459
|
+
failed: step2.failed?.length ?? 0,
|
|
460
|
+
expired: step2.expired?.length ?? 0,
|
|
461
|
+
pending: step2.pending?.length ?? 0,
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Convenience wrapper: pull the task + its results out of a RabbitState. */
|
|
467
|
+
export function aggregateTask(state, taskId, opts = {}) {
|
|
468
|
+
const task = state.getTask(taskId);
|
|
469
|
+
if (!task) throw new ProtocolError('NOT_FOUND', 'unknown task_id', { task_id: taskId });
|
|
470
|
+
return aggregate(task, state.results.values(), opts);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const STATUS_LABEL = Object.freeze({
|
|
474
|
+
consistent: '✅ consistent',
|
|
475
|
+
divergent: '❌ divergent',
|
|
476
|
+
'divergent-platform': '🟡 divergent-platform',
|
|
477
|
+
failed: '❌ failed',
|
|
478
|
+
partial: '🟠 partial',
|
|
479
|
+
unverifiable: '⚠️ unverifiable',
|
|
480
|
+
// v0.3.0 verdicts. Each keeps a distinct shape because these are read at a glance: `partial`
|
|
481
|
+
// (still out), `degraded` (finished badly) and `timeout` (never answered) must not look alike.
|
|
482
|
+
degraded: '🟧 degraded',
|
|
483
|
+
timeout: '⏱️ timeout',
|
|
484
|
+
cancelled: '🚫 cancelled',
|
|
485
|
+
refused: '⛔ refused',
|
|
486
|
+
pending: '⏳ pending',
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
function fmt(value) {
|
|
490
|
+
if (value === undefined) return '—';
|
|
491
|
+
if (value === null) return 'null';
|
|
492
|
+
if (typeof value === 'string') return value.length > 80 ? `${value.slice(0, 79)}…` : value;
|
|
493
|
+
return truncate(jcs(value), 80);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Markdown summary report (GET /v1/tasks/{id}/report?format=md). */
|
|
497
|
+
export function renderReportMarkdown(agg, task = null) {
|
|
498
|
+
const L = [];
|
|
499
|
+
L.push(`# W2M 汇总报告 — \`${agg.task_id}\``);
|
|
500
|
+
L.push('');
|
|
501
|
+
L.push(`**判定:${STATUS_LABEL[agg.status] ?? agg.status}**`);
|
|
502
|
+
L.push('');
|
|
503
|
+
L.push('| 字段 | 值 |');
|
|
504
|
+
L.push('|---|---|');
|
|
505
|
+
L.push(`| protocol_version | ${agg.protocol_version} |`);
|
|
506
|
+
L.push(`| 模式 | ${agg.mode} |`);
|
|
507
|
+
L.push(`| index_total | ${agg.index_total} |`);
|
|
508
|
+
L.push(`| 写入 | ${agg.write} |`);
|
|
509
|
+
L.push(`| halt | ${agg.halt} |`);
|
|
510
|
+
L.push(`| 创建者 | ${fmt(agg.created_by)} |`);
|
|
511
|
+
L.push(`| 创建时间 | ${agg.created_at} |`);
|
|
512
|
+
L.push(`| 汇总时间 | ${agg.computed_at} |`);
|
|
513
|
+
L.push(`| base_commit | \`${fmt(agg.expected.base_commit)}\` |`);
|
|
514
|
+
L.push(`| base_tree | \`${fmt(agg.expected.base_tree)}\` |`);
|
|
515
|
+
L.push(`| command_hash | \`${fmt(agg.expected.command_hash)}\` |`);
|
|
516
|
+
L.push('');
|
|
517
|
+
L.push('## 逐机结果');
|
|
518
|
+
L.push('');
|
|
519
|
+
L.push('| machine_id | index | lease | 判定 | status | exit_code | 说明 | 路径 |');
|
|
520
|
+
L.push('|---|---|---|---|---|---|---|---|');
|
|
521
|
+
for (const m of agg.machines) {
|
|
522
|
+
// v0.4.0: the trailing 路径 column is APPENDED, so every existing column keeps its name and
|
|
523
|
+
// position and a reader written against v0.3.9 output is unaffected. `-` is "the envelope did
|
|
524
|
+
// not say" (every v0.3.9 result), which is not the same claim as `relay`.
|
|
525
|
+
L.push(`| \`${m.machine_id}\` | ${fmt(m.index)} | ${m.lease_state} | ${m.outcome ?? '—'} | ${fmt(m.status)} `
|
|
526
|
+
+ `| ${fmt(m.exit_code)} | ${m.reasons.length ? m.reasons.join('; ') : (m.refusal_reason ?? '')} `
|
|
527
|
+
+ `| ${m.transport ?? '-'} |`);
|
|
528
|
+
}
|
|
529
|
+
L.push('');
|
|
530
|
+
if (agg.differences.length > 0) {
|
|
531
|
+
L.push('## 可比字段差异');
|
|
532
|
+
L.push('');
|
|
533
|
+
for (const d of agg.differences) {
|
|
534
|
+
L.push(`### \`${d.field}\``);
|
|
535
|
+
L.push('');
|
|
536
|
+
L.push('| machine_id | value |');
|
|
537
|
+
L.push('|---|---|');
|
|
538
|
+
for (const [machine, value] of Object.entries(d.values)) {
|
|
539
|
+
L.push(`| \`${machine}\` | \`${fmt(value)}\` |`);
|
|
540
|
+
}
|
|
541
|
+
L.push('');
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
L.push('## 判定步骤(§6.3,顺序不可合并)');
|
|
545
|
+
L.push('');
|
|
546
|
+
for (const s of agg.steps) {
|
|
547
|
+
L.push(`- **Step ${s.step} · ${s.name}** — ${truncate(jcs(s), 400)}`);
|
|
548
|
+
}
|
|
549
|
+
L.push('');
|
|
550
|
+
if (agg.notes.length > 0) {
|
|
551
|
+
L.push('## 备注');
|
|
552
|
+
L.push('');
|
|
553
|
+
for (const n of agg.notes) L.push(`- ${n}`);
|
|
554
|
+
L.push('');
|
|
555
|
+
}
|
|
556
|
+
if (task) {
|
|
557
|
+
L.push('## 原始命令');
|
|
558
|
+
L.push('');
|
|
559
|
+
L.push('```');
|
|
560
|
+
L.push(jcs(task.command_argv));
|
|
561
|
+
L.push('```');
|
|
562
|
+
L.push('');
|
|
563
|
+
}
|
|
564
|
+
return L.join('\n');
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** JSON report (GET /v1/tasks/{id}/report?format=json). */
|
|
568
|
+
export function renderReportJson(agg) {
|
|
569
|
+
return JSON.stringify(agg, null, 2);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Build a report of the requested format.
|
|
574
|
+
* @returns {{contentType:string, body:string, aggregate:object}}
|
|
575
|
+
*/
|
|
576
|
+
export function buildReport(state, taskId, { format = 'json', nowMs } = {}) {
|
|
577
|
+
const agg = aggregateTask(state, taskId, { nowMs });
|
|
578
|
+
const task = state.getTask(taskId);
|
|
579
|
+
if (format === 'md' || format === 'markdown') {
|
|
580
|
+
return { contentType: 'text/markdown; charset=utf-8', body: renderReportMarkdown(agg, task), aggregate: agg };
|
|
581
|
+
}
|
|
582
|
+
if (format === 'json') {
|
|
583
|
+
return { contentType: 'application/json; charset=utf-8', body: renderReportJson(agg), aggregate: agg };
|
|
584
|
+
}
|
|
585
|
+
throw new ProtocolError('BAD_REQUEST', 'format must be md|json', { format });
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Machine-readable verdict used by GET /v1/tasks/{id}. */
|
|
589
|
+
export function taskStatus(state, taskId, opts = {}) {
|
|
590
|
+
return aggregateTask(state, taskId, opts);
|
|
591
|
+
}
|