@twinsearth/w2m-dsh-plugin 1.2.6 → 1.3.0
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 +30 -29
- package/.claude-plugin/plugin.json +29 -29
- package/CHANGELOG.md +197 -0
- package/README.md +3 -3
- package/codex/mcp-servers.toml +1 -1
- package/docs/DATA-PLANE-COLLABORATION.md +82 -0
- package/lib/client.js +942 -941
- package/package.json +236 -235
- package/plugin.json +14 -14
- package/src/agent/agent.mjs +77 -1
- package/src/plugin/merge-hint.mjs +35 -0
- package/src/plugin/task-graph.mjs +153 -147
- package/src/plugin/tools.mjs +204 -19
- package/src/relay/fleet-messages.mjs +258 -241
- package/src/relay/report.mjs +11 -0
- package/src/relay/server.mjs +16 -1
- package/src/relay/state.mjs +135 -0
- package/src/util/dataflow.mjs +24 -1
- package/src/util/reduce-suggestion.mjs +107 -0
package/src/relay/state.mjs
CHANGED
|
@@ -711,6 +711,7 @@ export class RabbitState {
|
|
|
711
711
|
const t = entry.task;
|
|
712
712
|
if (!t || typeof t.task_id !== 'string') return false;
|
|
713
713
|
const createdAt = Number.isFinite(t.created_at_ms) ? t.created_at_ms : Date.parse(entry.ts ?? '');
|
|
714
|
+
|
|
714
715
|
const task = {
|
|
715
716
|
task_id: t.task_id,
|
|
716
717
|
mode: t.mode ?? 'replicate',
|
|
@@ -734,6 +735,11 @@ export class RabbitState {
|
|
|
734
735
|
created_at_ms: Number.isFinite(createdAt) ? createdAt : this.nowMs(),
|
|
735
736
|
command_hash: t.command_hash ?? null,
|
|
736
737
|
attempt: t.attempt ?? 1,
|
|
738
|
+
// v1.2.7: a revived task keeps its graph edges and assignment, or the scheduler silently becomes a different scheduler.
|
|
739
|
+
depends_on: Array.isArray(t.depends_on) ? t.depends_on : [],
|
|
740
|
+
assignee: typeof t.assignee === 'string' && t.assignee !== '' ? t.assignee : null,
|
|
741
|
+
reduce_of: typeof t.reduce_of === 'string' ? t.reduce_of : null,
|
|
742
|
+
reduce_inputs: Array.isArray(t.reduce_inputs) ? t.reduce_inputs : [],
|
|
737
743
|
leases: new Map(),
|
|
738
744
|
cancelled: false,
|
|
739
745
|
cancel_reason: null,
|
|
@@ -1720,6 +1726,24 @@ export class RabbitState {
|
|
|
1720
1726
|
: targetIds[0] ?? null)
|
|
1721
1727
|
: null;
|
|
1722
1728
|
|
|
1729
|
+
/**
|
|
1730
|
+
* v1.2.10: validate the reduce artifact list up front. A malformed list would otherwise become an offer the machine
|
|
1731
|
+
* cannot execute, and the failure would surface as a generic refusal on the executor instead of at dispatch.
|
|
1732
|
+
*/
|
|
1733
|
+
const rawReduceInputs = input.reduce_inputs === undefined || input.reduce_inputs === null ? [] : input.reduce_inputs;
|
|
1734
|
+
if (!Array.isArray(rawReduceInputs) || rawReduceInputs.length > 16) {
|
|
1735
|
+
throw new ProtocolError('BAD_REQUEST', 'reduce_inputs must be an array of at most 16 artifacts', { count: Array.isArray(rawReduceInputs) ? rawReduceInputs.length : null });
|
|
1736
|
+
}
|
|
1737
|
+
const normalizedReduceInputs = [];
|
|
1738
|
+
for (const artifact of rawReduceInputs) {
|
|
1739
|
+
const machineId = typeof artifact?.machine_id === 'string' && artifact.machine_id !== '' ? artifact.machine_id : null;
|
|
1740
|
+
const sha256 = typeof artifact?.sha256 === 'string' ? artifact.sha256.toLowerCase() : '';
|
|
1741
|
+
if (machineId === null || !/^[0-9a-f]{64}$/.test(sha256)) {
|
|
1742
|
+
throw new ProtocolError('BAD_REQUEST', 'each reduce input must be {machine_id, sha256}', { machine_id: machineId, sha256 });
|
|
1743
|
+
}
|
|
1744
|
+
normalizedReduceInputs.push({ machine_id: machineId, sha256 });
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1723
1747
|
const task = {
|
|
1724
1748
|
task_id: taskId,
|
|
1725
1749
|
mode,
|
|
@@ -1754,6 +1778,12 @@ export class RabbitState {
|
|
|
1754
1778
|
// the silent-drop failure this field-by-field literal exists to prevent.
|
|
1755
1779
|
depends_on: deps.ok === true ? deps.depends_on : [],
|
|
1756
1780
|
assignee: typeof input.assignee === 'string' && input.assignee !== '' ? input.assignee : null,
|
|
1781
|
+
/**
|
|
1782
|
+
* v1.2.10: a reduce task names the map task it reduces and the artifacts it consumes. The artifact LIST travels with
|
|
1783
|
+
* the task (never inferred by the reducer), because "which shards this reduce covers" is the completeness contract.
|
|
1784
|
+
*/
|
|
1785
|
+
reduce_of: typeof input.reduce_of === 'string' && input.reduce_of !== '' ? input.reduce_of : null,
|
|
1786
|
+
reduce_inputs: normalizedReduceInputs,
|
|
1757
1787
|
leases: new Map(),
|
|
1758
1788
|
cancelled: false,
|
|
1759
1789
|
cancel_reason: null,
|
|
@@ -1917,6 +1947,31 @@ export class RabbitState {
|
|
|
1917
1947
|
if (typeof dedupeKeyBase !== 'string' || dedupeKeyBase.length !== 64) {
|
|
1918
1948
|
throw new ProtocolError('INTERNAL', 'failed to compute dedupe_key');
|
|
1919
1949
|
}
|
|
1950
|
+
/**
|
|
1951
|
+
* v1.2.7: a dependency that is not settled means this task is NOT dispatched yet. Its leases are recorded as `waiting`,
|
|
1952
|
+
* which the sweeper ignores and redelivery skips, and `_releaseDependents` moves them to offers when the graph allows it.
|
|
1953
|
+
*/
|
|
1954
|
+
const dependencyStates = (task.depends_on ?? []).map((depId) => {
|
|
1955
|
+
const dep = this.tasks.get(depId);
|
|
1956
|
+
if (dep === undefined) return 'unknown';
|
|
1957
|
+
if (dep.cancelled === true) return 'cancelled';
|
|
1958
|
+
return this._taskSettled(dep) === true ? 'completed' : 'waiting';
|
|
1959
|
+
});
|
|
1960
|
+
const graphBlocked = dependencyStates.some((state) => state === 'cancelled' || state === 'unknown');
|
|
1961
|
+
const graphReady = graphBlocked !== true && dependencyStates.every((state) => state === 'completed');
|
|
1962
|
+
const waitingOn = graphReady === true ? [] : (task.depends_on ?? []);
|
|
1963
|
+
/**
|
|
1964
|
+
* v1.2.7: an assignee is a restriction, not a label. Only that machine gets a lease; another machine cannot accidentally
|
|
1965
|
+
* take work that was meant for one host. An unknown assignee is refused by name rather than producing a task nobody holds.
|
|
1966
|
+
*/
|
|
1967
|
+
if (task.assignee !== null && !this.devices.has(task.assignee)) {
|
|
1968
|
+
throw new ProtocolError('BAD_REQUEST', 'assignee is not a known device on this relay', { assignee: task.assignee });
|
|
1969
|
+
}
|
|
1970
|
+
if (task.assignee !== null) {
|
|
1971
|
+
const narrowed = targets.filter((device) => device.machine_id === task.assignee);
|
|
1972
|
+
targets.length = 0;
|
|
1973
|
+
targets.push(...narrowed);
|
|
1974
|
+
}
|
|
1920
1975
|
let lastSeq = this.seq;
|
|
1921
1976
|
targets.forEach((device, i) => {
|
|
1922
1977
|
const index = mode === 'replicate' ? 0 : i % indexTotal;
|
|
@@ -1950,6 +2005,12 @@ export class RabbitState {
|
|
|
1950
2005
|
task.leases.set(device.machine_id, lease);
|
|
1951
2006
|
return;
|
|
1952
2007
|
}
|
|
2008
|
+
if (graphReady !== true) {
|
|
2009
|
+
lease.state = 'waiting';
|
|
2010
|
+
lease.waiting_on = waitingOn;
|
|
2011
|
+
task.leases.set(device.machine_id, lease);
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
1953
2014
|
const gate = gateMachine(task, device);
|
|
1954
2015
|
if (gate) {
|
|
1955
2016
|
lease.state = 'refused';
|
|
@@ -2002,6 +2063,11 @@ export class RabbitState {
|
|
|
2002
2063
|
attempt: task.attempt,
|
|
2003
2064
|
deadline_ms: task.deadline_ms,
|
|
2004
2065
|
// v0.4.0: routing metadata, so a restarted relay re-offers with the same facts.
|
|
2066
|
+
// v1.2.7: the dependency graph and assignment belong to the task record, not to a reader's memory of it.
|
|
2067
|
+
depends_on: Array.isArray(task.depends_on) ? task.depends_on : [],
|
|
2068
|
+
assignee: task.assignee ?? null,
|
|
2069
|
+
reduce_of: task.reduce_of ?? null,
|
|
2070
|
+
reduce_inputs: Array.isArray(task.reduce_inputs) ? task.reduce_inputs : [],
|
|
2005
2071
|
origin_machine_id: task.origin_machine_id ?? null,
|
|
2006
2072
|
p2p: task.p2p ?? null,
|
|
2007
2073
|
},
|
|
@@ -2113,6 +2179,8 @@ export class RabbitState {
|
|
|
2113
2179
|
*/
|
|
2114
2180
|
origin_machine_id: task.origin_machine_id ?? null,
|
|
2115
2181
|
p2p: task.p2p ?? null,
|
|
2182
|
+
// v1.2.10: present only on a reduce task, so a normal offer stays byte-identical.
|
|
2183
|
+
...(task.reduce_of !== null ? { reduce_of: task.reduce_of, reduce_inputs: task.reduce_inputs } : {}),
|
|
2116
2184
|
cwd_rel: task.cwd_rel,
|
|
2117
2185
|
// Omitted when false, so a single-command offer for every existing caller stays byte-identical -- the
|
|
2118
2186
|
// same reason `stages` above is conditional.
|
|
@@ -2211,6 +2279,47 @@ export class RabbitState {
|
|
|
2211
2279
|
.map((t) => this.taskSummary(t));
|
|
2212
2280
|
}
|
|
2213
2281
|
|
|
2282
|
+
/** v1.2.7: a task is settled when no lease can still produce a result. Dependencies wait for that, not for a wording. */
|
|
2283
|
+
_taskSettled(task) {
|
|
2284
|
+
for (const lease of task.leases.values()) if (ACTIVE_LEASE_STATES.includes(lease.state)) return false;
|
|
2285
|
+
return true;
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
/**
|
|
2289
|
+
* v1.2.7: release tasks whose prerequisites are all settled.
|
|
2290
|
+
*
|
|
2291
|
+
* Called after a result is stored: a dependency that finished must actually move its dependents from `waiting` to an offer.
|
|
2292
|
+
* Without this the graph would be validated, stored, and then never scheduled -- the exact "validated but not enforced" gap.
|
|
2293
|
+
*/
|
|
2294
|
+
_releaseDependents(completedTaskId = null) {
|
|
2295
|
+
const released = [];
|
|
2296
|
+
for (const candidate of this.tasks.values()) {
|
|
2297
|
+
if (!Array.isArray(candidate.depends_on) || candidate.depends_on.length === 0) continue;
|
|
2298
|
+
if (completedTaskId !== null && !candidate.depends_on.includes(completedTaskId)) continue;
|
|
2299
|
+
const waiting = [...candidate.leases.values()].filter((lease) => lease.state === 'waiting');
|
|
2300
|
+
if (waiting.length === 0) continue;
|
|
2301
|
+
let ready = true;
|
|
2302
|
+
for (const depId of candidate.depends_on) {
|
|
2303
|
+
const dep = this.tasks.get(depId);
|
|
2304
|
+
if (dep === undefined) { ready = false; break; }
|
|
2305
|
+
if (dep.cancelled === true) { ready = false; break; }
|
|
2306
|
+
if (this._taskSettled(dep) !== true) { ready = false; break; }
|
|
2307
|
+
}
|
|
2308
|
+
if (ready !== true) continue;
|
|
2309
|
+
for (const lease of waiting) {
|
|
2310
|
+
lease.state = 'queued';
|
|
2311
|
+
lease.claimed_at_ms = this.nowMs();
|
|
2312
|
+
lease.lease_until_ms = this.nowMs() + this.leaseWindowMs();
|
|
2313
|
+
const device = this.devices.get(lease.machine_id);
|
|
2314
|
+
if (device === undefined) continue;
|
|
2315
|
+
this.emitOffer(candidate, lease);
|
|
2316
|
+
}
|
|
2317
|
+
this.emit('notice', { level: 'info', code: 'TASK_READY', message: candidate.task_id + ' is ready: dependencies settled', task_id: candidate.task_id });
|
|
2318
|
+
released.push(candidate.task_id);
|
|
2319
|
+
}
|
|
2320
|
+
return released;
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2214
2323
|
taskSummary(task) {
|
|
2215
2324
|
return {
|
|
2216
2325
|
task_id: task.task_id,
|
|
@@ -2222,6 +2331,26 @@ export class RabbitState {
|
|
|
2222
2331
|
degraded: task.degraded,
|
|
2223
2332
|
leases: [...task.leases.values()].map((l) => l.machine_id),
|
|
2224
2333
|
lease_states: Object.fromEntries([...task.leases.values()].map((l) => [l.machine_id, l.state])),
|
|
2334
|
+
/**
|
|
2335
|
+
* v1.2.7: the state the scheduler is actually in, derived here rather than by each reader.
|
|
2336
|
+
*
|
|
2337
|
+
* Before this, GET /v1/tasks carried no status at all, so a board classified a waiting task as "ready" (its dependencies
|
|
2338
|
+
* map empty) and a released task as "waiting" forever. The board must read the scheduler's own words or it is decoration.
|
|
2339
|
+
*/
|
|
2340
|
+
reduce_of: task.reduce_of ?? null,
|
|
2341
|
+
reduce_inputs: Array.isArray(task.reduce_inputs) ? task.reduce_inputs : [],
|
|
2342
|
+
status: (() => {
|
|
2343
|
+
if (task.cancelled === true) return 'cancelled';
|
|
2344
|
+
const leases = [...task.leases.values()];
|
|
2345
|
+
if (leases.some((lease) => lease.state === 'waiting')) return 'waiting';
|
|
2346
|
+
if (leases.some((lease) => ACTIVE_LEASE_STATES.includes(lease.state))) return 'active';
|
|
2347
|
+
const results = [...this.results.values()].filter((record) => record?.envelope?.task_id === task.task_id);
|
|
2348
|
+
if (results.length > 0 && results.every((record) => ['failed', 'crashed', 'refused', 'timeout'].includes(record?.envelope?.status))) return 'failed';
|
|
2349
|
+
return this._taskSettled(task) === true ? 'done' : 'unknown';
|
|
2350
|
+
})(),
|
|
2351
|
+
depends_on: Array.isArray(task.depends_on) ? task.depends_on : [],
|
|
2352
|
+
assignee: task.assignee ?? null,
|
|
2353
|
+
waiting_on: [...task.leases.values()].some((l) => l.state === 'waiting') ? (Array.isArray(task.depends_on) ? task.depends_on : []) : [],
|
|
2225
2354
|
};
|
|
2226
2355
|
}
|
|
2227
2356
|
|
|
@@ -2450,6 +2579,9 @@ export class RabbitState {
|
|
|
2450
2579
|
}
|
|
2451
2580
|
if (changed) task.degraded = 'partial';
|
|
2452
2581
|
}
|
|
2582
|
+
// v1.2.7: a lease that expires (or a machine that refuses) is a settlement too -- the dependent graph must move.
|
|
2583
|
+
this._releaseDependents();
|
|
2584
|
+
|
|
2453
2585
|
return expired;
|
|
2454
2586
|
}
|
|
2455
2587
|
|
|
@@ -2738,6 +2870,9 @@ export class RabbitState {
|
|
|
2738
2870
|
});
|
|
2739
2871
|
|
|
2740
2872
|
// §7: results go into the ledger so the dedupe index survives a restart.
|
|
2873
|
+
// v1.2.7: a settled task may be the prerequisite that frees its dependents.
|
|
2874
|
+
this._releaseDependents(taskId);
|
|
2875
|
+
|
|
2741
2876
|
this._ledger({ type: 'result.stored', task_id: taskId, machine_id: machineId, result: record });
|
|
2742
2877
|
if (machineId) {
|
|
2743
2878
|
const changed = task.leases.get(machineId);
|
package/src/util/dataflow.mjs
CHANGED
|
@@ -41,7 +41,8 @@ const SHA256 = /^[0-9a-f]{64}$/;
|
|
|
41
41
|
* A missing or malformed hash is refused by name -- the reduce step must never run on a partial fleet and call the answer
|
|
42
42
|
* complete.
|
|
43
43
|
*
|
|
44
|
-
* @param {{results?: Array<{machine_id?: string, mode?: string, stdout?: string, blob_sha256?: string}>,
|
|
44
|
+
* @param {{results?: Array<{machine_id?: string, mode?: string, stdout?: string, blob_sha256?: string, index?: number}>,
|
|
45
|
+
* expectedIndexTotal?: number|null,
|
|
45
46
|
* reducerMachineId?: string}} input
|
|
46
47
|
* @returns {{ok: true, reducerMachineId: string, inputs: Array<{machine_id: string, mode: 'inline'|'blob', stdout?: string, sha256?: string}>} |
|
|
47
48
|
* {ok: false, code: string, detail: object}}
|
|
@@ -51,6 +52,15 @@ export function buildReducePlan(input = {}) {
|
|
|
51
52
|
if (reducer === null) return { ok: false, code: 'REDUCER_MISSING', detail: {} };
|
|
52
53
|
const results = Array.isArray(input.results) ? input.results : null;
|
|
53
54
|
if (results === null || results.length === 0) return { ok: false, code: 'NO_MAP_RESULTS', detail: {} };
|
|
55
|
+
/**
|
|
56
|
+
* v1.2.7: what the task SAID would be produced, not just what arrived.
|
|
57
|
+
*
|
|
58
|
+
* Without an expected count the plan can only describe the reports it was handed, so a split run where one shard never
|
|
59
|
+
* reported produced a plan that looked complete. The count comes from the task (index_total); a missing index is refused
|
|
60
|
+
* by name before the reduce step can run on a partial fleet.
|
|
61
|
+
*/
|
|
62
|
+
const expectedTotal = Number.isInteger(input.expectedIndexTotal) && input.expectedIndexTotal > 0 ? input.expectedIndexTotal : null;
|
|
63
|
+
const seenIndexes = new Set();
|
|
54
64
|
|
|
55
65
|
const seen = new Set();
|
|
56
66
|
const inputs = [];
|
|
@@ -59,6 +69,14 @@ export function buildReducePlan(input = {}) {
|
|
|
59
69
|
if (machineId === null) return { ok: false, code: 'MAP_MACHINE_MISSING', detail: {} };
|
|
60
70
|
if (seen.has(machineId)) return { ok: false, code: 'MAP_MACHINE_DUPLICATE', detail: { machineId } };
|
|
61
71
|
seen.add(machineId);
|
|
72
|
+
if (expectedTotal !== null) {
|
|
73
|
+
const index = Number.isInteger(result?.index) ? result.index : null;
|
|
74
|
+
if (index === null) return { ok: false, code: 'MAP_INDEX_MISSING', detail: { machineId } };
|
|
75
|
+
if (index < 0 || index >= expectedTotal) {
|
|
76
|
+
return { ok: false, code: 'MAP_INDEX_OUT_OF_RANGE', detail: { machineId, index, expectedIndexTotal: expectedTotal } };
|
|
77
|
+
}
|
|
78
|
+
seenIndexes.add(index);
|
|
79
|
+
}
|
|
62
80
|
const mode = result?.mode === 'blob' ? 'blob' : 'inline';
|
|
63
81
|
if (mode === 'blob') {
|
|
64
82
|
const sha = typeof result?.blob_sha256 === 'string' ? result.blob_sha256.toLowerCase() : '';
|
|
@@ -68,5 +86,10 @@ export function buildReducePlan(input = {}) {
|
|
|
68
86
|
inputs.push({ machine_id: machineId, mode, stdout: typeof result?.stdout === 'string' ? result.stdout : '' });
|
|
69
87
|
}
|
|
70
88
|
}
|
|
89
|
+
if (expectedTotal !== null) {
|
|
90
|
+
const missing = [];
|
|
91
|
+
for (let index = 0; index < expectedTotal; index += 1) if (!seenIndexes.has(index)) missing.push(index);
|
|
92
|
+
if (missing.length > 0) return { ok: false, code: 'MISSING_SHARD', detail: { missing, expectedIndexTotal: expectedTotal } };
|
|
93
|
+
}
|
|
71
94
|
return { ok: true, reducerMachineId: reducer, inputs };
|
|
72
95
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reduce suggestion: what a reader can do about a finished map phase, without deciding it for them.
|
|
3
|
+
*
|
|
4
|
+
* ## Why a suggestion instead of a dispatch
|
|
5
|
+
*
|
|
6
|
+
* A reduce is a command run on a machine. Every other part of this fleet keeps "who decides to run a command" in the caller: the
|
|
7
|
+
* relay offers, the agent executes, the plugin dispatches when asked. A tool that dispatched the reduce itself would make a read
|
|
8
|
+
* into a write, and the caller would lose the ability to say "not on that machine" or "not with those inputs". So this function
|
|
9
|
+
* answers three questions and stops: is the data complete, who could reduce it, and what is the caller expected to do next.
|
|
10
|
+
*
|
|
11
|
+
* ## Why it is derived, not stored
|
|
12
|
+
*
|
|
13
|
+
* Two readers of the same aggregate must get the same suggestion, and reading must not change relay state. Everything here comes
|
|
14
|
+
* from the machine facts the aggregate already publishes (`blob_sha256`, `output_mode`, `stdout_head`, `stdout_bytes`) and the
|
|
15
|
+
* task's own `index_total`; nothing is written anywhere.
|
|
16
|
+
*/
|
|
17
|
+
import { buildReducePlan } from './dataflow.mjs';
|
|
18
|
+
|
|
19
|
+
/** The literal next step, kept in the payload so a caller cannot mistake the suggestion for an action. */
|
|
20
|
+
export const REDUCE_NEXT_STEP = 'dispatch the reduce yourself; this tool does not run it';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} machine a machine entry from the aggregate (full or compact shape)
|
|
24
|
+
* @returns {boolean} true when this machine actually reported a result
|
|
25
|
+
*/
|
|
26
|
+
export function machineReported(machine) {
|
|
27
|
+
if (machine === null || typeof machine !== 'object') return false;
|
|
28
|
+
if ((machine.envelope ?? machine.result ?? null) !== null) return true;
|
|
29
|
+
return typeof machine.blob_sha256 === 'string'
|
|
30
|
+
|| typeof machine.stdout_head === 'string'
|
|
31
|
+
|| Number.isFinite(machine.stdout_bytes)
|
|
32
|
+
|| (typeof machine.status === 'string' && machine.status !== '' && Number.isFinite(machine.exit_code));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {object} machine
|
|
37
|
+
* @returns {{machine_id: string, index: number|null, mode: 'inline'|'blob', stdout?: string, blob_sha256?: string}}
|
|
38
|
+
*/
|
|
39
|
+
export function reduceInputOf(machine) {
|
|
40
|
+
const envelope = machine?.envelope ?? machine?.result ?? null;
|
|
41
|
+
const blobSha256 = typeof envelope?.blob_sha256 === 'string' ? envelope.blob_sha256
|
|
42
|
+
: (typeof machine?.blob_sha256 === 'string' ? machine.blob_sha256 : null);
|
|
43
|
+
const head = typeof envelope?.stdout_head === 'string' && envelope.stdout_head !== '' ? envelope.stdout_head
|
|
44
|
+
: (typeof machine?.stdout_head === 'string' && machine.stdout_head !== '' ? machine.stdout_head : null);
|
|
45
|
+
const index = Number.isInteger(machine?.index) ? machine.index
|
|
46
|
+
: (Number.isInteger(envelope?.index) ? envelope.index : null);
|
|
47
|
+
return {
|
|
48
|
+
machine_id: typeof machine?.machine_id === 'string' ? machine.machine_id : '',
|
|
49
|
+
index,
|
|
50
|
+
mode: blobSha256 === null ? 'inline' : 'blob',
|
|
51
|
+
...(head === null ? {} : { stdout: head }),
|
|
52
|
+
...(blobSha256 === null ? {} : { blob_sha256: blobSha256 }),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {{machines?: object[], reducerMachineId?: string|null, expectedIndexTotal?: number|null}} input
|
|
58
|
+
* @returns {{
|
|
59
|
+
* ready: boolean,
|
|
60
|
+
* plan: object,
|
|
61
|
+
* candidates: Array<{machine_id: string, reason: string}>,
|
|
62
|
+
* missing: number[],
|
|
63
|
+
* next: string,
|
|
64
|
+
* }}
|
|
65
|
+
*/
|
|
66
|
+
export function suggestReduce(input = {}) {
|
|
67
|
+
const machines = Array.isArray(input.machines) ? input.machines : [];
|
|
68
|
+
const reducerMachineId = typeof input.reducerMachineId === 'string' && input.reducerMachineId !== ''
|
|
69
|
+
? input.reducerMachineId
|
|
70
|
+
: null;
|
|
71
|
+
const expectedIndexTotal = Number.isInteger(input.expectedIndexTotal) && input.expectedIndexTotal > 0
|
|
72
|
+
? input.expectedIndexTotal
|
|
73
|
+
: null;
|
|
74
|
+
|
|
75
|
+
const reported = machines.filter(machineReported);
|
|
76
|
+
const results = reported.map(reduceInputOf);
|
|
77
|
+
const candidates = reported.map((machine) => ({
|
|
78
|
+
machine_id: typeof machine?.machine_id === 'string' ? machine.machine_id : '',
|
|
79
|
+
reason: 'reported-shard',
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The reducer is a caller decision. A named reducer that never reported is a refusal with a name, not a suggestion to pick
|
|
84
|
+
* somebody else: changing the machine changes what the reduce runs against.
|
|
85
|
+
*/
|
|
86
|
+
let plan;
|
|
87
|
+
if (reducerMachineId !== null && !candidates.some((candidate) => candidate.machine_id === reducerMachineId)) {
|
|
88
|
+
plan = { ok: false, code: 'REDUCER_UNREPORTED', detail: { reducerMachineId } };
|
|
89
|
+
} else {
|
|
90
|
+
plan = buildReducePlan({
|
|
91
|
+
results,
|
|
92
|
+
reducerMachineId,
|
|
93
|
+
expectedIndexTotal,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const missing = plan.ok === false && plan.code === 'MISSING_SHARD' && Array.isArray(plan.detail?.missing)
|
|
98
|
+
? [...plan.detail.missing]
|
|
99
|
+
: [];
|
|
100
|
+
return {
|
|
101
|
+
ready: plan.ok === true,
|
|
102
|
+
plan,
|
|
103
|
+
candidates,
|
|
104
|
+
missing,
|
|
105
|
+
next: REDUCE_NEXT_STEP,
|
|
106
|
+
};
|
|
107
|
+
}
|