@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.
@@ -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
- if (task.halt === 'now') {
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
+ }