@twinsearth/w2m-dsh-plugin 1.2.2 → 1.2.5
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 +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +197 -27
- package/README.md +462 -462
- package/codex/mcp-servers.toml +1 -1
- package/lib/client.js +62 -3
- package/package.json +12 -4
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +4363 -4259
- package/src/plugin/ask-store.mjs +167 -0
- package/src/plugin/fleet-chat-route.mjs +46 -1
- package/src/plugin/schema-spec.mjs +82 -0
- package/src/plugin/task-graph.mjs +147 -0
- package/src/plugin/tools.mjs +7617 -7348
- package/src/plugin/turn.mjs +41 -0
- package/src/relay/fleet-members.mjs +78 -0
- package/src/relay/fleet-messages.mjs +224 -173
- package/src/relay/server.mjs +1575 -1498
- package/src/relay/state.mjs +121 -3
- package/src/relay/task-deps.mjs +92 -0
- package/src/util/dataflow.mjs +72 -0
package/src/relay/state.mjs
CHANGED
|
@@ -22,6 +22,8 @@ import { deviceFleetFields } from '../chatops/fleet-wiring.mjs';
|
|
|
22
22
|
import { scopeFleetId } from '../chatops/fleet-wiring.mjs';
|
|
23
23
|
// v0.8.1: the account-derived fleet key, so a machine can join with no pairing code.
|
|
24
24
|
import { planJoin } from '../chatops/fleet-join.mjs';
|
|
25
|
+
import { validateTaskDependencies } from './task-deps.mjs';
|
|
26
|
+
import { buildMembers, setMemberRole as applyMemberRole } from './fleet-members.mjs';
|
|
25
27
|
|
|
26
28
|
export const PROTOCOL_VERSION = 1;
|
|
27
29
|
|
|
@@ -506,6 +508,14 @@ export class RabbitState {
|
|
|
506
508
|
/** @type {{seq:number,event:object,target:string|null}[]} ring buffer, last N events */
|
|
507
509
|
this.buffer = [];
|
|
508
510
|
this.devices = new Map(); // machine_id -> device
|
|
511
|
+
/** v1.2.5: operator-assigned roles, kept apart from device records so a re-pair cannot change authority. */
|
|
512
|
+
this.roles = new Map(); // machine_id -> 'owner'|'worker'|'observer'
|
|
513
|
+
/** v1.2.5: read watermarks per session, per machine. */
|
|
514
|
+
this.readMarks = new Map(); // session_id -> Map<machine_id, {at_ms, message_id}>
|
|
515
|
+
/** v1.2.5: operator-assigned roles, kept apart from device records so a re-pair cannot change authority. */
|
|
516
|
+
this.roles = new Map(); // machine_id -> 'owner'|'worker'|'observer'
|
|
517
|
+
/** v1.2.5: read watermarks per session, per machine. */
|
|
518
|
+
this.readMarks = new Map(); // session_id -> Map<machine_id, {at_ms, message_id}>
|
|
509
519
|
this.tokens = new Map(); // device_token -> machine_id
|
|
510
520
|
this.tasks = new Map(); // task_id -> task
|
|
511
521
|
this.results = new Map(); // instanceKey -> {envelope, machine_id, received_at_ms, ...}
|
|
@@ -716,6 +726,9 @@ export class RabbitState {
|
|
|
716
726
|
requirements: t.requirements ?? {},
|
|
717
727
|
compare_policy: t.compare_policy ?? {},
|
|
718
728
|
halt: t.halt ?? 'never',
|
|
729
|
+
...(t.mode === 'mapreduce' ? { reduce_argv: Array.isArray(t.reduce_argv) ? t.reduce_argv : null, reducer_machine_id: t.reducer_machine_id ?? null } : {}),
|
|
730
|
+
max_attempts: Number.isInteger(t.max_attempts) ? t.max_attempts : 1,
|
|
731
|
+
retry_on: Array.isArray(t.retry_on) ? t.retry_on : [],
|
|
719
732
|
created_by: t.created_by ?? null,
|
|
720
733
|
created_at_ms: Number.isFinite(createdAt) ? createdAt : this.nowMs(),
|
|
721
734
|
command_hash: t.command_hash ?? null,
|
|
@@ -1517,12 +1530,20 @@ export class RabbitState {
|
|
|
1517
1530
|
// v0.4.0: refused BEFORE the task literal is built and before any offer is emitted, so a bad
|
|
1518
1531
|
// value cannot leave a half-created task behind. `POST /v1/task` is all-or-nothing.
|
|
1519
1532
|
const routing = RabbitState.validateTaskRouting(input);
|
|
1533
|
+
// v1.2.4: prerequisites are validated where the task is CREATED. A graph that only fails later puts the
|
|
1534
|
+
// refusal on a machine that did not create the problem.
|
|
1535
|
+
const deps = validateTaskDependencies({
|
|
1536
|
+
task_id: typeof input.task_id === 'string' ? input.task_id : null,
|
|
1537
|
+
depends_on: input.depends_on,
|
|
1538
|
+
tasksById: this.tasks,
|
|
1539
|
+
});
|
|
1540
|
+
if (deps.ok !== true) return { status: 'refused', refusal_reason: deps.code, detail: deps.detail ?? {} };
|
|
1520
1541
|
|
|
1521
1542
|
const mode = input.mode ?? 'replicate';
|
|
1522
|
-
if (mode !== 'replicate' && mode !== 'split' && mode !== 'broadcast' && mode !== 'pipeline' && mode !== 'compose') {
|
|
1543
|
+
if (mode !== 'replicate' && mode !== 'split' && mode !== 'broadcast' && mode !== 'pipeline' && mode !== 'compose' && mode !== 'mapreduce') {
|
|
1523
1544
|
throw new ProtocolError(
|
|
1524
1545
|
'BAD_REQUEST',
|
|
1525
|
-
'mode must be replicate|split|broadcast|pipeline|compose',
|
|
1546
|
+
'mode must be replicate|split|broadcast|pipeline|compose|mapreduce',
|
|
1526
1547
|
{ mode },
|
|
1527
1548
|
);
|
|
1528
1549
|
}
|
|
@@ -1591,9 +1612,48 @@ export class RabbitState {
|
|
|
1591
1612
|
}
|
|
1592
1613
|
const indexTotal = Number.isInteger(input.index_total) ? input.index_total : 1;
|
|
1593
1614
|
if (indexTotal < 1) throw new ProtocolError('BAD_REQUEST', 'index_total must be >= 1');
|
|
1615
|
+
/**
|
|
1616
|
+
* v1.2.4 mapreduce: the map half is ordinary dispatch; the reduce half is described here so the caller can run it as an
|
|
1617
|
+
* explicit second task once w2m_wait has verified every shard is present. Deliberately NOT a cascade: a relay that
|
|
1618
|
+
* launches the reduce itself would decide on a partial fleet, and the operator would see one task id for two executions.
|
|
1619
|
+
*/
|
|
1620
|
+
let reduceArgv = null;
|
|
1621
|
+
let reducerMachineId = null;
|
|
1622
|
+
if (mode === 'mapreduce') {
|
|
1623
|
+
if (indexTotal !== 1) {
|
|
1624
|
+
throw new ProtocolError('BAD_REQUEST', 'mode=mapreduce maps the whole input on every machine, so index_total must be 1', { index_total: indexTotal });
|
|
1625
|
+
}
|
|
1626
|
+
const argv = input.reduce_argv;
|
|
1627
|
+
if (!Array.isArray(argv) || argv.length === 0 || argv.some((part) => typeof part !== 'string' || part === '')) {
|
|
1628
|
+
throw new ProtocolError('BAD_REQUEST', 'mode=mapreduce requires reduce_argv as a non-empty string[]', { reduce_argv: argv ?? null });
|
|
1629
|
+
}
|
|
1630
|
+
reduceArgv = [...argv];
|
|
1631
|
+
if (input.reducer_machine_id !== undefined && input.reducer_machine_id !== null && typeof input.reducer_machine_id !== 'string') {
|
|
1632
|
+
throw new ProtocolError('BAD_REQUEST', 'reducer_machine_id must be a machine id string', { reducer_machine_id: input.reducer_machine_id });
|
|
1633
|
+
}
|
|
1634
|
+
reducerMachineId = typeof input.reducer_machine_id === 'string' && input.reducer_machine_id !== '' ? input.reducer_machine_id : null;
|
|
1635
|
+
} else if (input.reduce_argv !== undefined || input.reducer_machine_id !== undefined) {
|
|
1636
|
+
throw new ProtocolError('BAD_REQUEST', 'reduce_argv and reducer_machine_id are only valid with mode=mapreduce', { mode });
|
|
1637
|
+
}
|
|
1594
1638
|
|
|
1595
1639
|
const halt = input.halt ?? 'never';
|
|
1596
1640
|
if (halt !== 'never' && halt !== 'now') throw new ProtocolError('BAD_REQUEST', 'halt must be never|now', { halt });
|
|
1641
|
+
/**
|
|
1642
|
+
* v1.2.4 retry policy: how many attempts the task may take in total, and which terminal conditions justify
|
|
1643
|
+
* another. Default one attempt and no retry, so every existing caller keeps at-most-once semantics.
|
|
1644
|
+
*/
|
|
1645
|
+
const maxAttempts = Number.isInteger(input.max_attempts) ? input.max_attempts : 1;
|
|
1646
|
+
if (maxAttempts < 1 || maxAttempts > 5) {
|
|
1647
|
+
throw new ProtocolError('BAD_REQUEST', 'max_attempts must be an integer 1..5', { max_attempts: input.max_attempts });
|
|
1648
|
+
}
|
|
1649
|
+
const RETRYABLE = ['expired', 'failed', 'partial', 'timeout'];
|
|
1650
|
+
if (input.retry_on !== undefined && !Array.isArray(input.retry_on)) {
|
|
1651
|
+
throw new ProtocolError('BAD_REQUEST', 'retry_on must be an array of expired|failed|partial|timeout', { retry_on: input.retry_on });
|
|
1652
|
+
}
|
|
1653
|
+
const retryOn = Array.isArray(input.retry_on) ? [...new Set(input.retry_on)] : [];
|
|
1654
|
+
if (retryOn.some((reason) => typeof reason !== 'string' || !RETRYABLE.includes(reason))) {
|
|
1655
|
+
throw new ProtocolError('BAD_REQUEST', 'retry_on must list expired|failed|partial|timeout', { retry_on: input.retry_on, retryable: RETRYABLE });
|
|
1656
|
+
}
|
|
1597
1657
|
|
|
1598
1658
|
const taskId = input.task_id ?? this.nextId();
|
|
1599
1659
|
if (this.tasks.has(taskId)) {
|
|
@@ -1659,6 +1719,8 @@ export class RabbitState {
|
|
|
1659
1719
|
// machine. Named explicitly so it travels.
|
|
1660
1720
|
cwd_outside_project: input.cwd_outside_project === true,
|
|
1661
1721
|
index_total: indexTotal,
|
|
1722
|
+
// v1.2.4: the reduce half travels with the task so a caller (and a revived relay) can read what was intended.
|
|
1723
|
+
...(mode === 'mapreduce' ? { reduce_argv: reduceArgv, reducer_machine_id: reducerMachineId } : {}),
|
|
1662
1724
|
timeout_ms: timeoutMs,
|
|
1663
1725
|
write: input.write === true,
|
|
1664
1726
|
write_scope: input.write_scope ?? [],
|
|
@@ -1673,6 +1735,14 @@ export class RabbitState {
|
|
|
1673
1735
|
created_at_ms: now,
|
|
1674
1736
|
command_hash: commandHash,
|
|
1675
1737
|
attempt: 1,
|
|
1738
|
+
// v1.2.4: the policy travels with the task so a revived relay can still decide to retry after a restart.
|
|
1739
|
+
max_attempts: maxAttempts,
|
|
1740
|
+
retry_on: retryOn,
|
|
1741
|
+
// v1.2.4: prerequisites and an assignee travel with the task, because a graph the relay validated but did not
|
|
1742
|
+
// store would have to be re-derived later -- and a re-derivation that disagrees with the validation is exactly
|
|
1743
|
+
// the silent-drop failure this field-by-field literal exists to prevent.
|
|
1744
|
+
depends_on: deps.ok === true ? deps.depends_on : [],
|
|
1745
|
+
assignee: typeof input.assignee === 'string' && input.assignee !== '' ? input.assignee : null,
|
|
1676
1746
|
leases: new Map(),
|
|
1677
1747
|
cancelled: false,
|
|
1678
1748
|
cancel_reason: null,
|
|
@@ -2144,6 +2214,49 @@ export class RabbitState {
|
|
|
2144
2214
|
};
|
|
2145
2215
|
}
|
|
2146
2216
|
|
|
2217
|
+
/** v1.2.5: the fleet's members, with the role each holds and when it joined. */
|
|
2218
|
+
listMembers() {
|
|
2219
|
+
return buildMembers({ devices: this.devices, roles: this.roles });
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
/** v1.2.5: an operator decision about one machine. Refused by name, never silently ignored. */
|
|
2223
|
+
setMemberRole(machineId, role) {
|
|
2224
|
+
const result = applyMemberRole({ machineId, role, devices: this.devices, roles: this.roles });
|
|
2225
|
+
if (result.ok === true) {
|
|
2226
|
+
this.emit('notice', { level: 'info', code: 'MEMBER_ROLE_SET', message: `${machineId} is now ${role}`, machine_id: machineId });
|
|
2227
|
+
this._ledger({ type: 'member.role', machine_id: machineId, role });
|
|
2228
|
+
}
|
|
2229
|
+
return result;
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
/**
|
|
2233
|
+
* v1.2.5: record that a machine has read a session up to a marker.
|
|
2234
|
+
*
|
|
2235
|
+
* Only forward: a stale marker arriving after a newer one must not move the watermark backwards, or the unread count would
|
|
2236
|
+
* grow for messages the machine has already seen.
|
|
2237
|
+
*/
|
|
2238
|
+
markRead(sessionId, machineId, marker = {}) {
|
|
2239
|
+
if (typeof sessionId !== 'string' || sessionId === '') return { ok: false, code: 'BAD_SESSION' };
|
|
2240
|
+
if (typeof machineId !== 'string' || machineId === '') return { ok: false, code: 'BAD_MACHINE' };
|
|
2241
|
+
const atMs = Number.isFinite(marker.at_ms) ? Math.floor(marker.at_ms) : this.nowMs();
|
|
2242
|
+
const messageId = typeof marker.message_id === 'string' && marker.message_id !== '' ? marker.message_id : null;
|
|
2243
|
+
if (!this.readMarks.has(sessionId)) this.readMarks.set(sessionId, new Map());
|
|
2244
|
+
const perSession = this.readMarks.get(sessionId);
|
|
2245
|
+
const previous = perSession.get(machineId) ?? null;
|
|
2246
|
+
if (previous !== null && previous.at_ms > atMs) return { ok: true, mark: { ...previous }, advanced: false };
|
|
2247
|
+
const mark = { at_ms: atMs, message_id: messageId };
|
|
2248
|
+
perSession.set(machineId, mark);
|
|
2249
|
+
return { ok: true, mark: { ...mark }, advanced: true };
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
/** v1.2.5: this machine's watermark for one session (null when it never read it). */
|
|
2253
|
+
readMark(sessionId, machineId) {
|
|
2254
|
+
const perSession = this.readMarks.get(sessionId) ?? null;
|
|
2255
|
+
if (perSession === null) return null;
|
|
2256
|
+
const mark = perSession.get(machineId) ?? null;
|
|
2257
|
+
return mark === null ? null : { ...mark };
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2147
2260
|
cancelTask(taskId, reason = 'cancelled_by_operator') {
|
|
2148
2261
|
const task = this.requireTask(taskId);
|
|
2149
2262
|
if (task.cancelled) return task;
|
|
@@ -2309,7 +2422,12 @@ export class RabbitState {
|
|
|
2309
2422
|
task_id: task.task_id,
|
|
2310
2423
|
machine_id: lease.machine_id,
|
|
2311
2424
|
});
|
|
2312
|
-
|
|
2425
|
+
// v1.2.4: a caller that named "expired" in retry_on gets the hand-off halt=now always had, as long as it
|
|
2426
|
+
// has attempts left. Without the policy this stays book-keeping only -- the pre-1.2.4 behaviour.
|
|
2427
|
+
const retryExpired = task.halt === 'now'
|
|
2428
|
+
|| (Number.isInteger(task.max_attempts) && task.attempt < task.max_attempts
|
|
2429
|
+
&& Array.isArray(task.retry_on) && task.retry_on.includes('expired'));
|
|
2430
|
+
if (retryExpired) {
|
|
2313
2431
|
task.attempt += 1;
|
|
2314
2432
|
this._takeover(task, lease, nowMs);
|
|
2315
2433
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task dependencies: a task may name prerequisites, and the graph is validated where the task is CREATED.
|
|
3
|
+
*
|
|
4
|
+
* ## Why the refusal belongs at enqueue time
|
|
5
|
+
*
|
|
6
|
+
* A dependency checked when a worker picks the task up puts the refusal on a machine that did not create the problem, at a
|
|
7
|
+
* time when the operator has long stopped looking. Everything here is a pure function over the tasks already known, so the
|
|
8
|
+
* relay can answer "is this graph sound" before it stores anything.
|
|
9
|
+
*
|
|
10
|
+
* ## What is refused, and why each one is a refusal rather than a warning
|
|
11
|
+
*
|
|
12
|
+
* - a dependency on a task that does not exist: it can never complete, so the task can never be ready;
|
|
13
|
+
* - a self-dependency and any cycle: no topological order exists, so "ready" would never arrive;
|
|
14
|
+
* - a duplicate id in the list: the same edge stated twice is not two edges, and accepting it would make the stored list
|
|
15
|
+
* disagree with the graph it describes.
|
|
16
|
+
*/
|
|
17
|
+
export const TASK_DEPS_REFUSAL = Object.freeze({
|
|
18
|
+
DEPENDS_ON_NOT_ARRAY: 'TASK_DEPENDS_ON_NOT_ARRAY',
|
|
19
|
+
DEPENDENCY_UNKNOWN: 'TASK_DEPENDENCY_UNKNOWN',
|
|
20
|
+
DEPENDENCY_SELF: 'TASK_DEPENDENCY_SELF',
|
|
21
|
+
DEPENDENCY_DUPLICATE: 'TASK_DEPENDENCY_DUPLICATE',
|
|
22
|
+
DEPENDENCY_CYCLE: 'TASK_DEPENDENCY_CYCLE',
|
|
23
|
+
MAX_DEPENDENCIES: 'TASK_DEPENDENCY_TOO_MANY',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** A cap exists so a hostile caller cannot make the cycle check quadratic without bound. */
|
|
27
|
+
export const MAX_DEPENDENCIES = 32;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Walk the ancestors of a task id and report whether a new edge would close a cycle.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} from the dependency being added
|
|
33
|
+
* @param {string} target the task being created
|
|
34
|
+
* @param {Map<string, object>} tasksById
|
|
35
|
+
* @returns {string[]|null} the cycle path when one exists, null otherwise
|
|
36
|
+
*/
|
|
37
|
+
export function wouldCycle(from, target, tasksById) {
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
const stack = [from];
|
|
40
|
+
while (stack.length > 0) {
|
|
41
|
+
const id = stack.pop();
|
|
42
|
+
if (id === target) return [target, from];
|
|
43
|
+
if (seen.has(id)) continue;
|
|
44
|
+
seen.add(id);
|
|
45
|
+
const task = tasksById.get(id) ?? null;
|
|
46
|
+
const deps = Array.isArray(task?.depends_on) ? task.depends_on : [];
|
|
47
|
+
for (const dep of deps) stack.push(dep);
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {{task_id?: string|null, depends_on?: unknown, tasksById?: Map<string, object>}} input
|
|
54
|
+
* @returns {{ok: true, depends_on: string[]} | {ok: false, code: string, detail?: object}}
|
|
55
|
+
*/
|
|
56
|
+
export function validateTaskDependencies({ task_id = null, depends_on = null, tasksById = new Map() } = {}) {
|
|
57
|
+
if (depends_on === undefined || depends_on === null) return { ok: true, depends_on: [] };
|
|
58
|
+
if (!Array.isArray(depends_on)) return { ok: false, code: TASK_DEPS_REFUSAL.DEPENDS_ON_NOT_ARRAY };
|
|
59
|
+
if (depends_on.length > MAX_DEPENDENCIES) {
|
|
60
|
+
return { ok: false, code: TASK_DEPS_REFUSAL.MAX_DEPENDENCIES, detail: { count: depends_on.length, max: MAX_DEPENDENCIES } };
|
|
61
|
+
}
|
|
62
|
+
const list = [];
|
|
63
|
+
for (const dep of depends_on) {
|
|
64
|
+
if (typeof dep !== 'string' || dep === '') {
|
|
65
|
+
return { ok: false, code: TASK_DEPS_REFUSAL.DEPENDENCY_UNKNOWN, detail: { dependency: dep } };
|
|
66
|
+
}
|
|
67
|
+
if (task_id !== null && dep === task_id) return { ok: false, code: TASK_DEPS_REFUSAL.DEPENDENCY_SELF, detail: { task_id } };
|
|
68
|
+
if (list.includes(dep)) return { ok: false, code: TASK_DEPS_REFUSAL.DEPENDENCY_DUPLICATE, detail: { dependency: dep } };
|
|
69
|
+
if (!tasksById.has(dep)) return { ok: false, code: TASK_DEPS_REFUSAL.DEPENDENCY_UNKNOWN, detail: { dependency: dep } };
|
|
70
|
+
const cycle = task_id === null ? null : wouldCycle(dep, task_id, tasksById);
|
|
71
|
+
if (cycle !== null) return { ok: false, code: TASK_DEPS_REFUSAL.DEPENDENCY_CYCLE, detail: { path: cycle } };
|
|
72
|
+
list.push(dep);
|
|
73
|
+
}
|
|
74
|
+
return { ok: true, depends_on: list };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* What a task may do, given its prerequisites' states.
|
|
79
|
+
*
|
|
80
|
+
* @param {{depends_on?: string[], tasksById?: Map<string, object>}} input
|
|
81
|
+
* @returns {'ready'|'blocked'|'waiting'}
|
|
82
|
+
*/
|
|
83
|
+
export function readinessOf({ depends_on = [], tasksById = new Map() } = {}) {
|
|
84
|
+
let waiting = false;
|
|
85
|
+
for (const dep of depends_on) {
|
|
86
|
+
const task = tasksById.get(dep) ?? null;
|
|
87
|
+
const status = typeof task?.status === 'string' ? task.status : 'unknown';
|
|
88
|
+
if (status === 'failed' || status === 'cancelled' || status === 'refused') return 'blocked';
|
|
89
|
+
if (status !== 'completed' && status !== 'done') waiting = true;
|
|
90
|
+
}
|
|
91
|
+
return waiting ? 'waiting' : 'ready';
|
|
92
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data flow across the fleet: map results that are too big to return inline travel as blobs, and the reduce step names
|
|
3
|
+
* exactly which blob it read.
|
|
4
|
+
*
|
|
5
|
+
* ## Why a plan instead of a pipe
|
|
6
|
+
*
|
|
7
|
+
* Machines do not send data to each other directly: the relay is the only channel every machine can reach, and it already
|
|
8
|
+
* stores content-addressed blobs. So the shape is map -> PUT blob -> reduce by sha256. The decisions worth testing are the
|
|
9
|
+
* boundaries: when an output is inline, when it becomes a blob, and what happens when a blob a reduce depends on is not
|
|
10
|
+
* there. A pipeline that silently drops a shard is worse than one that fails.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Stage stdout at or below this travels inline in the result envelope. */
|
|
14
|
+
export const MAX_INLINE_BYTES = 64 * 1024;
|
|
15
|
+
|
|
16
|
+
/** Past this a stage output is not a result any more: it is a data problem the operator has to see. */
|
|
17
|
+
export const HARD_CAP_BYTES = 16 * 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Decide how one machine's stage output travels.
|
|
21
|
+
*
|
|
22
|
+
* @param {{machineId?: string, stageIndex?: number, stdoutBytes?: number}} input
|
|
23
|
+
* @returns {{ok: true, mode: 'inline'|'blob', stdoutBytes: number} | {ok: false, code: string, detail: object}}
|
|
24
|
+
*/
|
|
25
|
+
export function planStageOutput(input = {}) {
|
|
26
|
+
const machineId = typeof input.machineId === 'string' && input.machineId !== '' ? input.machineId : null;
|
|
27
|
+
if (machineId === null) return { ok: false, code: 'STAGE_MACHINE_MISSING', detail: {} };
|
|
28
|
+
const stdoutBytes = Number.isFinite(input.stdoutBytes) ? Math.max(0, Math.floor(input.stdoutBytes)) : 0;
|
|
29
|
+
if (stdoutBytes > HARD_CAP_BYTES) {
|
|
30
|
+
return { ok: false, code: 'STAGE_OUTPUT_TOO_LARGE', detail: { machineId, stdoutBytes, hardCapBytes: HARD_CAP_BYTES } };
|
|
31
|
+
}
|
|
32
|
+
return { ok: true, mode: stdoutBytes <= MAX_INLINE_BYTES ? 'inline' : 'blob', stdoutBytes };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build the reduce input list from the map results.
|
|
39
|
+
*
|
|
40
|
+
* Every map machine must contribute exactly one entry: inline text is carried as-is, a blob result must name a sha256.
|
|
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
|
+
* complete.
|
|
43
|
+
*
|
|
44
|
+
* @param {{results?: Array<{machine_id?: string, mode?: string, stdout?: string, blob_sha256?: string}>,
|
|
45
|
+
* reducerMachineId?: string}} input
|
|
46
|
+
* @returns {{ok: true, reducerMachineId: string, inputs: Array<{machine_id: string, mode: 'inline'|'blob', stdout?: string, sha256?: string}>} |
|
|
47
|
+
* {ok: false, code: string, detail: object}}
|
|
48
|
+
*/
|
|
49
|
+
export function buildReducePlan(input = {}) {
|
|
50
|
+
const reducer = typeof input.reducerMachineId === 'string' && input.reducerMachineId !== '' ? input.reducerMachineId : null;
|
|
51
|
+
if (reducer === null) return { ok: false, code: 'REDUCER_MISSING', detail: {} };
|
|
52
|
+
const results = Array.isArray(input.results) ? input.results : null;
|
|
53
|
+
if (results === null || results.length === 0) return { ok: false, code: 'NO_MAP_RESULTS', detail: {} };
|
|
54
|
+
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
const inputs = [];
|
|
57
|
+
for (const result of results) {
|
|
58
|
+
const machineId = typeof result?.machine_id === 'string' && result.machine_id !== '' ? result.machine_id : null;
|
|
59
|
+
if (machineId === null) return { ok: false, code: 'MAP_MACHINE_MISSING', detail: {} };
|
|
60
|
+
if (seen.has(machineId)) return { ok: false, code: 'MAP_MACHINE_DUPLICATE', detail: { machineId } };
|
|
61
|
+
seen.add(machineId);
|
|
62
|
+
const mode = result?.mode === 'blob' ? 'blob' : 'inline';
|
|
63
|
+
if (mode === 'blob') {
|
|
64
|
+
const sha = typeof result?.blob_sha256 === 'string' ? result.blob_sha256.toLowerCase() : '';
|
|
65
|
+
if (!SHA256.test(sha)) return { ok: false, code: 'BLOB_MISSING', detail: { machineId, sha256: result?.blob_sha256 ?? null } };
|
|
66
|
+
inputs.push({ machine_id: machineId, mode, sha256: sha });
|
|
67
|
+
} else {
|
|
68
|
+
inputs.push({ machine_id: machineId, mode, stdout: typeof result?.stdout === 'string' ? result.stdout : '' });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { ok: true, reducerMachineId: reducer, inputs };
|
|
72
|
+
}
|