@twinsearth/w2m-dsh-plugin 1.3.1 → 1.3.11

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.
Files changed (52) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +588 -0
  4. package/bin/w2m-maintenance.mjs +112 -0
  5. package/codex/mcp-servers.toml +1 -1
  6. package/deploy/maintenance/bootstrap.ps1 +30 -0
  7. package/deploy/maintenance/bootstrap.sh +59 -0
  8. package/deploy/server/w2m-maintenance.path +10 -0
  9. package/deploy/server/w2m-maintenance.service +20 -0
  10. package/deploy/windows/w2m-maintenance.xml +31 -0
  11. package/docs/COLLAB/COLLABORATION-MODEL.md +240 -0
  12. package/docs/COLLAB/EXECUTION-AUTH-MODEL.md +250 -0
  13. package/docs/COLLAB/M1-ACCEPTANCE-RUNBOOK.md +70 -0
  14. package/docs/COLLAB/M1-MAINTENANCE.md +313 -0
  15. package/docs/COLLAB/M2-TASK-GRANT.md +231 -0
  16. package/docs/COLLAB/M3-GIT-NATIVE-TASKS.md +291 -0
  17. package/docs/COLLAB/M4-PROJECT-ROOM.md +260 -0
  18. package/docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md +263 -0
  19. package/docs/COLLAB/M6-CROSS-NETWORK-RELIABILITY.md +231 -0
  20. package/docs/COLLAB/M7-METRICS-ACCEPTANCE.md +263 -0
  21. package/docs/COLLAB/TASK-MODEL.md +281 -0
  22. package/docs/MULTI-MACHINE-COLLABORATION-PLAN.md +186 -0
  23. package/docs/RELEASE-STABLE.md +193 -0
  24. package/docs/acceptance/1.3.10-beta.10/M1-LIVE-DIAG.json +27 -0
  25. package/docs/acceptance/1.3.10-beta.10/M1-LIVE-POSTUPDATE-DIAG.json +18 -0
  26. package/docs/acceptance/1.3.10-beta.10/M1-LIVE-RESTART.json +24 -0
  27. package/docs/acceptance/1.3.10-beta.10/M1-LIVE-UPDATE.json +24 -0
  28. package/docs/acceptance/1.3.11/M1-LIVE-DIAG.json +26 -0
  29. package/package.json +22 -4
  30. package/plugin.json +1 -1
  31. package/src/acceptance/evidence.mjs +79 -0
  32. package/src/agent/agent.mjs +64 -2
  33. package/src/agent/grant-gate.mjs +82 -0
  34. package/src/agent/maintenance-ferry.mjs +111 -0
  35. package/src/agent/outbox.mjs +138 -0
  36. package/src/agent/patch-artifact.mjs +53 -0
  37. package/src/agent/worktree.mjs +109 -0
  38. package/src/maintenance/actions.mjs +203 -0
  39. package/src/maintenance/grant.mjs +139 -0
  40. package/src/maintenance/operator-key.mjs +151 -0
  41. package/src/maintenance/protocol.mjs +204 -0
  42. package/src/maintenance/runner.mjs +189 -0
  43. package/src/plugin/tools.mjs +1 -1
  44. package/src/relay/blob-sessions.mjs +148 -0
  45. package/src/relay/merge-queue.mjs +137 -0
  46. package/src/relay/project-locks.mjs +179 -0
  47. package/src/relay/project-notifications.mjs +182 -0
  48. package/src/relay/project-reviews.mjs +161 -0
  49. package/src/relay/project-room.mjs +163 -0
  50. package/src/relay/reliability-metrics.mjs +180 -0
  51. package/src/relay/server.mjs +164 -1
  52. package/src/relay/state.mjs +128 -1
@@ -767,7 +767,11 @@ export class RelayServer {
767
767
  // scanner the surface exists.
768
768
  throw new ProtocolError('NOT_FOUND', 'metrics are not enabled on this relay', { hint: 'start the relay with --metrics' });
769
769
  }
770
- const body = renderMetrics(this.state, { nowMs: this.state.nowMs() });
770
+ const publishedBody = renderMetrics(this.state, { nowMs: this.state.nowMs() });
771
+ // v1.3.10-beta.23: the M7 reliability registry is appended. It renders nothing until a call site records something, so
772
+ // the existing exposition is unchanged on relays that never instrument.
773
+ const registryBody = typeof this.state.metrics?.renderPrometheus === 'function' ? this.state.metrics.renderPrometheus() : '';
774
+ const body = registryBody === '' ? publishedBody : publishedBody + '# reliability registry\n' + registryBody;
771
775
  res.writeHead(200, {
772
776
  'content-type': METRICS_CONTENT_TYPE,
773
777
  'content-length': Buffer.byteLength(body, 'utf8'),
@@ -811,6 +815,37 @@ export class RelayServer {
811
815
  return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...result });
812
816
  }
813
817
 
818
+ /**
819
+ * v1.3.10-beta.14: project-room writes (M4).
820
+ *
821
+ * Above the shared device guard on purpose: an operator token is not a device token, and these change who may act on
822
+ * shared state, so they take the same credential that dispatches tasks.
823
+ */
824
+ if (method === 'POST' && (path === '/v1/projects' || /^\/v1\/projects\/[^/]+\/members$/.test(path))) {
825
+ const auth = this.authorizeTaskDispatch(req);
826
+ if (auth.role !== 'operator') throw new ProtocolError('FORBIDDEN_ACTION', 'only the operator may change project membership', { required_role: 'owner' });
827
+ const body = takeJson();
828
+ const membersMatch = /^\/v1\/projects\/([^/]+)\/members$/.exec(path);
829
+ if (membersMatch !== null) {
830
+ const target = decodeURIComponent(membersMatch[1]);
831
+ const added = this.state.projects.addMember({ projectId: target, member: { kind: body?.member?.kind, id: body?.member?.id, name: body?.member?.name ?? null }, role: body?.role ?? 'worker' });
832
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, member: added.member, snapshot: this.state.projects.snapshot({ projectId: target }) });
833
+ }
834
+ const created = this.state.projects.createProject({
835
+ name: body?.name,
836
+ origin: body?.origin ?? null,
837
+ createdBy: { kind: 'operator', id: typeof body?.created_by?.id === 'string' ? body.created_by.id : 'operator', name: typeof body?.created_by?.name === 'string' ? body.created_by.name : null },
838
+ });
839
+ const createdId = created.project.project.project_id;
840
+ if (Array.isArray(body?.members)) {
841
+ for (const entry of body.members) {
842
+ this.state.projects.addMember({ projectId: createdId, member: { kind: entry?.kind, id: entry?.id, name: entry?.name ?? null }, role: entry?.role ?? 'worker' });
843
+ }
844
+ }
845
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, project: this.state.projects.snapshot({ projectId: createdId }) });
846
+ }
847
+
848
+
814
849
  /* ---- everything below requires `Authorization: Bearer <device_token>` ---- */
815
850
  const token = bearerToken(req);
816
851
  const device = token ? this.state.authenticate(token) : null;
@@ -819,6 +854,85 @@ export class RelayServer {
819
854
  }
820
855
  this.state.touchDevice(device.machine_id);
821
856
 
857
+ const projectLockMatch = /^\/v1\/projects\/([^/]+)\/locks(?:\/([^/]+)(?:\/(renew))?)?$/.exec(path);
858
+ if (projectLockMatch !== null && method !== undefined) {
859
+ const projectId = decodeURIComponent(projectLockMatch[1]);
860
+ const lockId = projectLockMatch[2] === undefined ? null : decodeURIComponent(projectLockMatch[2]);
861
+ const action = projectLockMatch[3] ?? null;
862
+ const roomMember = { kind: 'machine', id: device.machine_id };
863
+ const snapshot = this.state.projectSnapshot(projectId);
864
+ const isDefault = snapshot.project.project_id === this.state.defaultProjectId;
865
+ const membership = isDefault ? { role: 'worker' } : this.state.projects.memberOf({ projectId, member: roomMember });
866
+ if (membership === null) return sendJson(res, 404, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: false, error: { code: 'NOT_FOUND', message: 'unknown project' } });
867
+ const roleRank = { observer: 0, worker: 1, maintainer: 2, owner: 3 };
868
+ const canWrite = roleRank[membership.role] >= roleRank.worker;
869
+ if (method === 'GET' && lockId === null) {
870
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, locks: this.state.locks.list({ projectId }) });
871
+ }
872
+ if (method === 'POST' && lockId === null) {
873
+ if (!canWrite) return sendJson(res, 403, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: false, error: { code: 'FORBIDDEN_ACTION', message: 'acquiring a lock needs worker or above', required_role: 'worker', current_role: membership.role } });
874
+ const body = takeJson();
875
+ const acquired = this.state.locks.acquire({ projectId, kind: body?.kind, target: body?.target, holder: { kind: "machine", id: device.machine_id }, reason: body?.reason ?? null, leaseMs: Number.isFinite(body?.lease_ms) ? body.lease_ms : undefined });
876
+ if (acquired.ok === true) this.state.metrics?.inc('wm_lock_acquired_total', { kind: String(body?.kind ?? '') });
877
+ else if (acquired.code === 'LOCK_HELD') this.state.metrics?.inc('wm_lock_conflict_total', { kind: String(body?.kind ?? '') });
878
+ return sendJson(res, acquired.ok ? 200 : 409, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...acquired });
879
+ }
880
+ if (method === 'POST' && lockId !== null && action === 'renew') {
881
+ const renewed = this.state.locks.renew({ projectId, lockId, holder: { kind: "machine", id: device.machine_id } });
882
+ return sendJson(res, renewed.ok ? 200 : 409, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...renewed });
883
+ }
884
+ if (method === 'DELETE' && lockId !== null) {
885
+ const force = url.searchParams.get('force') === '1';
886
+ const reason = url.searchParams.get('reason');
887
+ const released = force
888
+ ? this.state.locks.forceRelease({ projectId, lockId, actor: { kind: "machine", id: device.machine_id }, actorRole: membership.role, reason })
889
+ : this.state.locks.release({ projectId, lockId, holder: { kind: "machine", id: device.machine_id } });
890
+ return sendJson(res, released.ok ? 200 : 409, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...released });
891
+ }
892
+ return sendJson(res, 405, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'unsupported lock operation' } });
893
+ }
894
+
895
+ /**
896
+ * v1.3.10-beta.14: project-room reads (M4). Below the device guard, so the device is already authenticated.
897
+ *
898
+ * A machine sees the default project and the projects it is a member of; a project it is not a member of answers
899
+ * NOT_FOUND, so a project existence is not leaked. The snapshot carries last_event_seq, and the events route returns
900
+ * the slice after since_seq, which is what lets a client resume after a disconnect.
901
+ */
902
+ if (method === 'GET' && (path === '/v1/projects' || path.startsWith('/v1/projects/'))) {
903
+ const match = /^\/v1\/projects(?:\/([^/]+))?(?:\/(events|members))?$/.exec(path);
904
+ if (match === null) throw new ProtocolError('NOT_FOUND', 'unknown route', { path });
905
+ const projectId = match[1] === undefined ? null : decodeURIComponent(match[1]);
906
+ const sub = match[2] ?? null;
907
+ const readSnapshot = (id) => {
908
+ let snapshot;
909
+ try {
910
+ snapshot = this.state.projectSnapshot(id);
911
+ } catch (error) {
912
+ if (error?.code === 'BAD_REQUEST' || error?.code === 'PROJECT_NOT_FOUND') throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: id });
913
+ throw error;
914
+ }
915
+ if (snapshot.project.project_id === this.state.defaultProjectId) return snapshot;
916
+ const member = this.state.projects.memberOf({ projectId: id, member: { kind: 'machine', id: device.machine_id } });
917
+ if (member === null) throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: id });
918
+ return snapshot;
919
+ };
920
+ if (projectId === null) {
921
+ const mine = this.state.projects.listProjects({ member: { kind: 'machine', id: device.machine_id } }).projects;
922
+ if (!mine.some((project) => project.project_id === this.state.defaultProjectId)) mine.unshift({ project_id: this.state.defaultProjectId, name: 'default', origin: null });
923
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, projects: mine });
924
+ }
925
+ if (sub === null) return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...readSnapshot(projectId) });
926
+ if (sub === 'events') {
927
+ readSnapshot(projectId);
928
+ const raw = url.searchParams.get('since_seq');
929
+ const sinceSeq = raw !== null && /^[0-9]+$/.test(raw) ? Number(raw) : 0;
930
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...this.state.projectEvents(projectId, { sinceSeq }) });
931
+ }
932
+ throw new ProtocolError('BAD_REQUEST', 'unsupported project route', { method, path });
933
+ }
934
+
935
+
822
936
  // v0.3.0 §9: writes are always checked (tolerating unsigned unless required);
823
937
  // reads are only checked when the relay was explicitly told to require
824
938
  // signatures. See `enforceSignature` for the reasoning.
@@ -871,6 +985,38 @@ export class RelayServer {
871
985
  const { _instanceKey, ...clean } = result;
872
986
  return sendJson(res, 200, clean);
873
987
  }
988
+ /**
989
+ * v1.3.10-beta.20: chunked blob sessions (M6). A >64 KiB artifact is uploaded in idempotent chunks and only becomes a
990
+ * blob when the assembled bytes hash to the declared sha256; the session answers "which chunks are missing" so an
991
+ * interrupted upload resumes instead of restarting.
992
+ */
993
+ const blobSessionCreate = method === 'POST' && path === '/v1/fleet/blob-sessions';
994
+ const blobSessionChunk = /^\/v1\/fleet\/blob-sessions\/([^/]+)\/chunks\/([0-9]+)$/.exec(path);
995
+ const blobSessionCommit = /^\/v1\/fleet\/blob-sessions\/([^/]+)\/commit$/.exec(path);
996
+ const blobSessionStatus = /^\/v1\/fleet\/blob-sessions\/([^/]+)$/.exec(path);
997
+ if (blobSessionCreate || blobSessionChunk !== null || blobSessionCommit !== null || blobSessionStatus !== null) {
998
+ const wrap = (fn) => { try { return fn(); } catch (error) { if (typeof error?.code === "string") throw new ProtocolError(error.code, error.message, error.detail ?? null); throw error; } };
999
+ if (blobSessionCreate) {
1000
+ const body = takeJson();
1001
+ const created = wrap(() => this.state.blobSessions.create({ machineId: device.machine_id, sha256: body?.sha256, size: body?.size, chunkBytes: body?.chunk_bytes }));
1002
+ return sendJson(res, 201, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...created });
1003
+ }
1004
+ if (method === 'PUT' && blobSessionChunk !== null) {
1005
+ const data = rawBody ?? Buffer.alloc(0);
1006
+ const stored = wrap(() => this.state.blobSessions.putChunk({ sessionId: decodeURIComponent(blobSessionChunk[1]), index: Number(blobSessionChunk[2]), data }));
1007
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...stored });
1008
+ }
1009
+ if (method === 'POST' && blobSessionCommit !== null) {
1010
+ const committed = wrap(() => this.state.blobSessions.commit({ sessionId: decodeURIComponent(blobSessionCommit[1]) }));
1011
+ return sendJson(res, committed.ok === true ? 201 : 409, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...committed });
1012
+ }
1013
+ if (method === 'GET' && blobSessionStatus !== null) {
1014
+ const status = wrap(() => this.state.blobSessions.status({ sessionId: decodeURIComponent(blobSessionStatus[1]) }));
1015
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...status });
1016
+ }
1017
+ throw new ProtocolError('BAD_REQUEST', 'a blob session is created with POST, chunks with PUT, committed with POST', {});
1018
+ }
1019
+
874
1020
  /**
875
1021
  * v1.0.7: the content-addressed blob channel (#1).
876
1022
  *
@@ -956,6 +1102,23 @@ export class RelayServer {
956
1102
  // Same code and shape for "no such blob" and "not your fleet's" -- deliberately indistinguishable.
957
1103
  throw new ProtocolError('NOT_FOUND', 'no such blob is readable by this device', { hash });
958
1104
  }
1105
+ const rangeParam = url.searchParams.get('range');
1106
+ if (rangeParam !== null) {
1107
+ const match = /^([0-9]+)-([0-9]+)$/.exec(rangeParam);
1108
+ if (match === null) throw new ProtocolError('BAD_REQUEST', 'range must be start-end', { range: rangeParam });
1109
+ const start = Number(match[1]);
1110
+ const end = Math.min(Number(match[2]), bytes.length - 1);
1111
+ if (!(start >= 0 && start <= end && start < bytes.length)) throw new ProtocolError('BAD_REQUEST', 'range is outside the blob', { range: rangeParam, bytes: bytes.length });
1112
+ const slice = bytes.subarray(start, end + 1);
1113
+ res.writeHead(206, {
1114
+ 'content-type': 'application/octet-stream',
1115
+ 'content-length': slice.length,
1116
+ 'content-range': 'bytes ' + start + '-' + end + '/' + bytes.length,
1117
+ 'cache-control': 'no-store',
1118
+ });
1119
+ res.end(slice);
1120
+ return true;
1121
+ }
959
1122
  res.writeHead(200, {
960
1123
  'content-type': 'application/octet-stream',
961
1124
  'content-length': bytes.length,
@@ -25,6 +25,12 @@ import { planJoin } from '../chatops/fleet-join.mjs';
25
25
  import { validateTaskDependencies } from './task-deps.mjs';
26
26
  import { buildMembers, isMemberRole, setMemberRole as applyMemberRole } from './fleet-members.mjs';
27
27
  import { canonicalSignedPayload, publicKeyFromBase64 } from '../util/envelope-signature.mjs';
28
+ import { createProjectRoom } from './project-room.mjs';
29
+ import { createProjectLocks } from './project-locks.mjs';
30
+ import { createProjectReviews } from './project-reviews.mjs';
31
+ import { createProjectNotifications } from './project-notifications.mjs';
32
+ import { createBlobSessions } from './blob-sessions.mjs';
33
+ import { createMetrics } from './reliability-metrics.mjs';
28
34
 
29
35
  export const PROTOCOL_VERSION = 1;
30
36
 
@@ -509,6 +515,37 @@ export class RabbitState {
509
515
  /** @type {{seq:number,event:object,target:string|null}[]} ring buffer, last N events */
510
516
  this.buffer = [];
511
517
  this.devices = new Map(); // machine_id -> device
518
+ // v1.3.10-beta.2: operator signing keys seen with a dispatch; agents pin the first one they receive.
519
+ this.operatorKeys = new Map();
520
+ // v1.3.10-beta.13: shared project objects (M4). Tasks name a project; readers get snapshot + events.
521
+ this.metrics = createMetrics();
522
+ // Every project-room event is counted once, here: the room's own events and the lock/review/notification events projected
523
+ // into it both pass through this callback, so the counter cannot double-count a projection.
524
+ const metricsEvent = (event) => {
525
+ try { this.metrics.inc('wm_project_events_total', { type: event.type }); } catch { /* metrics never break the room */ }
526
+ };
527
+ this.projects = createProjectRoom({ now: () => this.nowMs(), onEvent: metricsEvent });
528
+ this.defaultProjectId = 'default';
529
+ // M5 primitives share the project room's event stream: a lock, review or notification is a project event with the
530
+ // module's own sequence attached, so one reader can reconstruct "what happened" from a single cursor.
531
+ const roomEvent = (event) => {
532
+ try {
533
+ this.projects.appendEvent({ projectId: event.payload.project_id, type: event.type, actor: { kind: 'operator', id: 'relay' }, payload: { ...event.payload, module_seq: event.seq } });
534
+ } catch { /* an event about a project that is gone is history, not an error */ }
535
+ };
536
+ this.locks = createProjectLocks({ now: () => this.nowMs(), onEvent: roomEvent });
537
+ this.reviews = createProjectReviews({ now: () => this.nowMs(), onEvent: roomEvent });
538
+ this.notifications = createProjectNotifications({ now: () => this.nowMs(), onEvent: roomEvent });
539
+ // v1.3.10-beta.20: a committed chunk session becomes a fleet blob with the same verified bytes. A store failure does not
540
+ // undo the commit (the session is gone), but it is reported through the route so a caller never believes a blob exists.
541
+ this.blobSessions = createBlobSessions({
542
+ now: () => this.nowMs(),
543
+ onCommit: (blob) => {
544
+ const device = this.devices.get(blob.machineId);
545
+ if (device === undefined) return;
546
+ this.putFleetBlob(device, blob.bytes);
547
+ },
548
+ });
512
549
  /** v1.2.5: operator-assigned roles, kept apart from device records so a re-pair cannot change authority. */
513
550
  this.roles = new Map(); // machine_id -> 'owner'|'worker'|'observer'
514
551
  /** v1.2.5: read watermarks per session, per machine. */
@@ -705,6 +742,10 @@ export class RabbitState {
705
742
 
706
743
  /** @returns {boolean} true when a new task was created. */
707
744
  _applyLedgerEntry(entry) {
745
+ if (entry?.type === 'operator.key.registered' && entry.key && typeof entry.key.key_id === 'string') {
746
+ this.operatorKeys.set(entry.key.key_id, { ...entry.key });
747
+ return false;
748
+ }
708
749
  if (!entry || typeof entry.type !== 'string') return false;
709
750
  switch (entry.type) {
710
751
  case 'task.created': {
@@ -738,7 +779,8 @@ export class RabbitState {
738
779
  // v1.2.7: a revived task keeps its graph edges and assignment, or the scheduler silently becomes a different scheduler.
739
780
  depends_on: Array.isArray(t.depends_on) ? t.depends_on : [],
740
781
  assignee: typeof t.assignee === 'string' && t.assignee !== '' ? t.assignee : null,
741
- reduce_of: typeof t.reduce_of === 'string' ? t.reduce_of : null,
782
+ project_id: typeof t.project_id === 'string' ? t.project_id : null,
783
+ reduce_of: typeof t.reduce_of === 'string' ? t.reduce_of : null,
742
784
  reduce_inputs: Array.isArray(t.reduce_inputs) ? t.reduce_inputs : [],
743
785
  leases: new Map(),
744
786
  cancelled: false,
@@ -1730,6 +1772,49 @@ export class RabbitState {
1730
1772
  * v1.2.10: validate the reduce artifact list up front. A malformed list would otherwise become an offer the machine
1731
1773
  * cannot execute, and the failure would surface as a generic refusal on the executor instead of at dispatch.
1732
1774
  */
1775
+ /**
1776
+ * v1.3.10-beta.2: a maintenance request is opaque to the relay (the root runner is the authority) but its shape and
1777
+ * size are checked here so a malformed request is refused at dispatch instead of on a machine it was never meant for.
1778
+ */
1779
+ const rawMaintenance = input.maintenance ?? null;
1780
+ let normalizedMaintenance = null;
1781
+ if (rawMaintenance !== null) {
1782
+ if (typeof rawMaintenance !== 'object' || Array.isArray(rawMaintenance) || Buffer.byteLength(JSON.stringify(rawMaintenance), 'utf8') > 64 * 1024) {
1783
+ throw new ProtocolError('BAD_REQUEST', 'maintenance must be an object of at most 64 KiB', {});
1784
+ }
1785
+ if (typeof rawMaintenance.request_id !== 'string' || rawMaintenance.request_id === '') {
1786
+ throw new ProtocolError('BAD_REQUEST', 'maintenance.request_id is required', {});
1787
+ }
1788
+ normalizedMaintenance = rawMaintenance;
1789
+ }
1790
+ /**
1791
+ * v1.3.10-beta.3: a task-level grant travels with the dispatch. The relay checks its shape and time window so a bad
1792
+ * grant is refused at dispatch, but the agent is the verifier — the relay never decides authorisation on its own.
1793
+ */
1794
+ const rawGrant = input.grant ?? null;
1795
+ let normalizedGrant = null;
1796
+ if (rawGrant !== null) {
1797
+ if (typeof rawGrant !== 'object' || Array.isArray(rawGrant) || Buffer.byteLength(JSON.stringify(rawGrant), 'utf8') > 8 * 1024) {
1798
+ throw new ProtocolError('BAD_REQUEST', 'grant must be an object of at most 8 KiB', {});
1799
+ }
1800
+ if (typeof rawGrant.grant_id !== 'string' || rawGrant.grant_id === '' || !/^[0-9a-f]{64}$/.test(String(rawGrant.argv_hash ?? ''))) {
1801
+ throw new ProtocolError('BAD_REQUEST', 'grant needs grant_id and a 64-hex argv_hash', {});
1802
+ }
1803
+ normalizedGrant = rawGrant;
1804
+ }
1805
+ const rawOperatorKey = input.operator_key ?? null;
1806
+ let normalizedOperatorKey = null;
1807
+ if (rawOperatorKey !== null) {
1808
+ if (typeof rawOperatorKey !== 'object' || Array.isArray(rawOperatorKey)) throw new ProtocolError('BAD_REQUEST', 'operator_key must be an object', {});
1809
+ if (typeof rawOperatorKey.key_id !== 'string' || typeof rawOperatorKey.public_key_pem !== 'string') {
1810
+ throw new ProtocolError('BAD_REQUEST', 'operator_key needs key_id and public_key_pem', {});
1811
+ }
1812
+ normalizedOperatorKey = { key_id: rawOperatorKey.key_id, public_key_pem: rawOperatorKey.public_key_pem, created_at: rawOperatorKey.created_at ?? new Date(this.nowMs()).toISOString() };
1813
+ if (this.operatorKeys.has(normalizedOperatorKey.key_id) === false) {
1814
+ this.operatorKeys.set(normalizedOperatorKey.key_id, normalizedOperatorKey);
1815
+ this._ledger({ type: 'operator.key.registered', key: normalizedOperatorKey });
1816
+ }
1817
+ }
1733
1818
  const rawReduceInputs = input.reduce_inputs === undefined || input.reduce_inputs === null ? [] : input.reduce_inputs;
1734
1819
  if (!Array.isArray(rawReduceInputs) || rawReduceInputs.length > 16) {
1735
1820
  throw new ProtocolError('BAD_REQUEST', 'reduce_inputs must be an array of at most 16 artifacts', { count: Array.isArray(rawReduceInputs) ? rawReduceInputs.length : null });
@@ -1782,7 +1867,14 @@ export class RabbitState {
1782
1867
  * v1.2.10: a reduce task names the map task it reduces and the artifacts it consumes. The artifact LIST travels with
1783
1868
  * the task (never inferred by the reducer), because "which shards this reduce covers" is the completeness contract.
1784
1869
  */
1870
+ project_id: this.ensureProject(input.project_id ?? null),
1785
1871
  reduce_of: typeof input.reduce_of === 'string' && input.reduce_of !== '' ? input.reduce_of : null,
1872
+ grant: normalizedGrant,
1873
+ grant_summary: normalizedGrant === null ? null : { grant_id: normalizedGrant.grant_id, argv_hash: normalizedGrant.argv_hash, key_id: normalizedGrant.signature?.key_id ?? null },
1874
+ /** v1.3.10-beta.2: a signed maintenance request travels with the task; the relay carries it, the root runner verifies it. */
1875
+ maintenance: normalizedMaintenance,
1876
+ operator_key: normalizedOperatorKey,
1877
+ maintenance_summary: normalizedMaintenance === null ? null : { request_id: normalizedMaintenance.request_id ?? null, action: normalizedMaintenance.action ?? null },
1786
1878
  reduce_inputs: normalizedReduceInputs,
1787
1879
  leases: new Map(),
1788
1880
  cancelled: false,
@@ -2043,6 +2135,7 @@ export class RabbitState {
2043
2135
  type: 'task.created',
2044
2136
  task: {
2045
2137
  task_id: task.task_id,
2138
+ project_id: task.project_id,
2046
2139
  mode: task.mode,
2047
2140
  command_argv: task.command_argv,
2048
2141
  cwd_rel: task.cwd_rel,
@@ -2067,6 +2160,8 @@ export class RabbitState {
2067
2160
  depends_on: Array.isArray(task.depends_on) ? task.depends_on : [],
2068
2161
  assignee: task.assignee ?? null,
2069
2162
  reduce_of: task.reduce_of ?? null,
2163
+ maintenance_summary: task.maintenance_summary ?? null,
2164
+ grant_summary: task.grant_summary ?? null,
2070
2165
  reduce_inputs: Array.isArray(task.reduce_inputs) ? task.reduce_inputs : [],
2071
2166
  origin_machine_id: task.origin_machine_id ?? null,
2072
2167
  p2p: task.p2p ?? null,
@@ -2074,6 +2169,11 @@ export class RabbitState {
2074
2169
  leases: [...task.leases.values()].map((l) => ({ ...l })),
2075
2170
  });
2076
2171
 
2172
+ // v1.3.10-beta.13: a task belongs to a project, and the project room records the link as an event both clients see.
2173
+ try {
2174
+ this.projects.appendEvent({ projectId: task.project_id, type: 'task.linked', actor: { kind: 'operator', id: 'relay' }, payload: { task_id: taskId, mode: task.mode } });
2175
+ } catch { /* the ledger already carries the task; the room event is additive */ }
2176
+
2077
2177
  const refusedOnly = [...task.leases.values()].every((l) => l.state === 'refused');
2078
2178
  if (refusedOnly) {
2079
2179
  // §6.3 Step 0 territory: a task nobody may execute is still a valid, reportable task.
@@ -2181,6 +2281,9 @@ export class RabbitState {
2181
2281
  p2p: task.p2p ?? null,
2182
2282
  // v1.2.10: present only on a reduce task, so a normal offer stays byte-identical.
2183
2283
  ...(task.reduce_of !== null ? { reduce_of: task.reduce_of, reduce_inputs: task.reduce_inputs } : {}),
2284
+ // v1.3.10-beta.2: the signed request and its operator key travel together; the agent pins the key on first sight.
2285
+ ...(task.maintenance !== null && task.maintenance !== undefined ? { maintenance: task.maintenance, operator_key: task.operator_key } : {}),
2286
+ ...(task.grant !== null && task.grant !== undefined ? { grant: task.grant } : {}),
2184
2287
  cwd_rel: task.cwd_rel,
2185
2288
  // Omitted when false, so a single-command offer for every existing caller stays byte-identical -- the
2186
2289
  // same reason `stages` above is conditional.
@@ -2201,6 +2304,27 @@ export class RabbitState {
2201
2304
  return event;
2202
2305
  }
2203
2306
 
2307
+ /**
2308
+ * Resolve the project a task belongs to. The implicit default project is created lazily, so existing callers keep working
2309
+ * without naming one; an explicit unknown project is a refusal, not a new project.
2310
+ */
2311
+ ensureProject(projectId) {
2312
+ const wanted = typeof projectId === 'string' && projectId !== '' ? projectId : this.defaultProjectId;
2313
+ try {
2314
+ this.projects.snapshot({ projectId: wanted });
2315
+ return wanted;
2316
+ } catch (error) {
2317
+ if (error?.code !== 'PROJECT_NOT_FOUND') throw error;
2318
+ if (wanted !== this.defaultProjectId) throw new ProtocolError('BAD_REQUEST', 'unknown project_id', { project_id: wanted });
2319
+ this.projects.createProject({ projectId: this.defaultProjectId, name: 'default', createdBy: { kind: 'operator', id: 'relay', name: 'relay' } });
2320
+ return wanted;
2321
+ }
2322
+ }
2323
+
2324
+ /** Snapshot helpers the project routes use; kept on state so the relay has one owner of the room. */
2325
+ projectSnapshot(projectId) { return this.projects.snapshot({ projectId: this.ensureProject(projectId) }); }
2326
+ projectEvents(projectId, options = {}) { return this.projects.events({ projectId: this.ensureProject(projectId), ...options }); }
2327
+
2204
2328
  getTask(taskId) { return this.tasks.get(taskId) ?? null; }
2205
2329
 
2206
2330
  requireTask(taskId) {
@@ -2337,8 +2461,11 @@ export class RabbitState {
2337
2461
  * Before this, GET /v1/tasks carried no status at all, so a board classified a waiting task as "ready" (its dependencies
2338
2462
  * map empty) and a released task as "waiting" forever. The board must read the scheduler's own words or it is decoration.
2339
2463
  */
2464
+ project_id: task.project_id ?? null,
2340
2465
  reduce_of: task.reduce_of ?? null,
2341
2466
  reduce_inputs: Array.isArray(task.reduce_inputs) ? task.reduce_inputs : [],
2467
+ maintenance_request_id: task.maintenance_summary?.request_id ?? null,
2468
+ grant_id: task.grant_summary?.grant_id ?? null,
2342
2469
  status: (() => {
2343
2470
  if (task.cancelled === true) return 'cancelled';
2344
2471
  const leases = [...task.leases.values()];