@twinsearth/w2m-dsh-plugin 1.3.1 → 1.4.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.
Files changed (57) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +709 -0
  4. package/bin/w2m-grant.mjs +98 -0
  5. package/bin/w2m-maintenance.mjs +112 -0
  6. package/codex/mcp-servers.toml +1 -1
  7. package/deploy/maintenance/bootstrap.ps1 +30 -0
  8. package/deploy/maintenance/bootstrap.sh +61 -0
  9. package/deploy/server/w2m-maintenance.path +10 -0
  10. package/deploy/server/w2m-maintenance.service +20 -0
  11. package/deploy/windows/w2m-maintenance.xml +31 -0
  12. package/docs/COLLAB/COLLABORATION-MODEL.md +240 -0
  13. package/docs/COLLAB/EXECUTION-AUTH-MODEL.md +361 -0
  14. package/docs/COLLAB/M1-ACCEPTANCE-RUNBOOK.md +70 -0
  15. package/docs/COLLAB/M1-MAINTENANCE.md +313 -0
  16. package/docs/COLLAB/M2-TASK-GRANT.md +231 -0
  17. package/docs/COLLAB/M3-GIT-NATIVE-TASKS.md +291 -0
  18. package/docs/COLLAB/M4-PROJECT-ROOM.md +260 -0
  19. package/docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md +263 -0
  20. package/docs/COLLAB/M6-CROSS-NETWORK-RELIABILITY.md +231 -0
  21. package/docs/COLLAB/M7-METRICS-ACCEPTANCE.md +263 -0
  22. package/docs/COLLAB/TASK-MODEL.md +281 -0
  23. package/docs/MULTI-MACHINE-COLLABORATION-PLAN.md +186 -0
  24. package/docs/RELEASE-STABLE.md +193 -0
  25. package/docs/acceptance/1.3.10-beta.10/M1-LIVE-DIAG.json +27 -0
  26. package/docs/acceptance/1.3.10-beta.10/M1-LIVE-POSTUPDATE-DIAG.json +18 -0
  27. package/docs/acceptance/1.3.10-beta.10/M1-LIVE-RESTART.json +24 -0
  28. package/docs/acceptance/1.3.10-beta.10/M1-LIVE-UPDATE.json +24 -0
  29. package/docs/acceptance/1.3.11/M1-LIVE-DIAG.json +26 -0
  30. package/docs/acceptance/1.3.12-beta.1/M1-LIVE-DIAG.json +26 -0
  31. package/docs/acceptance/1.4.0/M1-LIVE-MAINTENANCE.json +20 -0
  32. package/docs/acceptance/1.4.0/REGRESSION-1.4.0.json +81 -0
  33. package/package.json +26 -5
  34. package/plugin.json +1 -1
  35. package/src/acceptance/evidence.mjs +79 -0
  36. package/src/agent/agent.mjs +141 -3
  37. package/src/agent/grant-gate.mjs +89 -0
  38. package/src/agent/maintenance-ferry.mjs +111 -0
  39. package/src/agent/outbox.mjs +138 -0
  40. package/src/agent/patch-artifact.mjs +53 -0
  41. package/src/agent/worktree.mjs +118 -0
  42. package/src/maintenance/actions.mjs +203 -0
  43. package/src/maintenance/grant.mjs +139 -0
  44. package/src/maintenance/operator-key.mjs +151 -0
  45. package/src/maintenance/protocol.mjs +204 -0
  46. package/src/maintenance/runner.mjs +189 -0
  47. package/src/plugin/tools.mjs +1 -1
  48. package/src/relay/blob-sessions.mjs +148 -0
  49. package/src/relay/merge-queue.mjs +137 -0
  50. package/src/relay/project-locks.mjs +179 -0
  51. package/src/relay/project-notifications.mjs +184 -0
  52. package/src/relay/project-refs.mjs +110 -0
  53. package/src/relay/project-reviews.mjs +161 -0
  54. package/src/relay/project-room.mjs +163 -0
  55. package/src/relay/reliability-metrics.mjs +180 -0
  56. package/src/relay/server.mjs +382 -1
  57. package/src/relay/state.mjs +308 -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,254 @@ 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
+
849
+ /**
850
+ * v1.3.12-beta.2: grant administration (execution-authorization model).
851
+ *
852
+ * Revoking is an operator act: it stops one authorisation from being dispatched. The task list is the audit view --
853
+ * every task that carried a grant, with the argv hash and key id it was authorised under -- and deliberately returns
854
+ * only operator-readable data, never result bodies.
855
+ */
856
+ if (path === '/v1/grants/revoke' || path === '/v1/grants') {
857
+ const auth = this.authorizeTaskDispatch(req);
858
+ if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'only the operator may administer grants');
859
+ if (method === 'POST' && path === '/v1/grants/revoke') {
860
+ const body = takeJson();
861
+ const revoked = this.state.revokeGrant({ grantId: body?.grant_id, reason: body?.reason ?? null });
862
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...revoked });
863
+ }
864
+ if (method === 'GET' && path === '/v1/grants') {
865
+ const audit = this.state.grantedTasks();
866
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...audit });
867
+ }
868
+ throw new ProtocolError('BAD_REQUEST', 'grants accept GET (audit) or POST /revoke', { method, path });
869
+ }
870
+
871
+ /**
872
+ * v1.3.13-beta.4: project refs and the merge queue (task model: branch, merge, conflict).
873
+ *
874
+ * Reads take the device token of a paired machine; advancing a ref and settling a queue item are operator acts, because
875
+ * they state where the branch is and what a patch did. A merge that would move the head from a base that is no longer
876
+ * current is refused and the item stays queued -- the model never silently rewrites history.
877
+ */
878
+ const refsMatch = /^\/v1\/projects\/([^/]+)\/refs(?:\/advance)?$/.exec(path);
879
+ if (refsMatch !== null) {
880
+ const projectId = decodeURIComponent(refsMatch[1]);
881
+ const wantsAdvance = path.endsWith('/advance');
882
+ if (!wantsAdvance && method === 'GET') {
883
+ const token = bearerToken(req);
884
+ if (token === null || this.state.authenticate(token) === null) throw new ProtocolError('UNAUTHORIZED', 'reading refs needs a paired device token');
885
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, refs: this.state.refs.listRefs({ projectId }) });
886
+ }
887
+ if (wantsAdvance && method === 'POST') {
888
+ const auth = this.authorizeTaskDispatch(req);
889
+ if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'advancing a ref is an operator act');
890
+ const body = takeJson();
891
+ const moved = this.state.refs.advance({ projectId, branch: body?.branch, from: body?.from ?? null, to: body?.to, reason: body?.reason ?? null });
892
+ return sendJson(res, moved.ok === true ? 200 : 409, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...moved });
893
+ }
894
+ throw new ProtocolError('BAD_REQUEST', 'refs accept GET or POST /advance', { method, path });
895
+ }
896
+ const mergeMatch = /^\/v1\/projects\/([^/]+)\/merge-queue(?:\/([^/]+))?(?:\/(complete))?$/.exec(path);
897
+ if (mergeMatch !== null) {
898
+ const projectId = decodeURIComponent(mergeMatch[1]);
899
+ const queueId = mergeMatch[2] === undefined ? null : decodeURIComponent(mergeMatch[2]);
900
+ const action = mergeMatch[3] ?? null;
901
+ if (method === 'GET' && queueId === null) {
902
+ const token = bearerToken(req);
903
+ if (token === null || this.state.authenticate(token) === null) throw new ProtocolError('UNAUTHORIZED', 'reading the merge queue needs a paired device token');
904
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, items: this.state.refs.listQueue({ projectId }) });
905
+ }
906
+ const auth = this.authorizeTaskDispatch(req);
907
+ if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'merge queue mutations are operator acts');
908
+ if (method === 'POST' && queueId === null) {
909
+ const body = takeJson();
910
+ const queued = this.state.refs.enqueue({ projectId, branch: body?.branch, patchSha256: body?.patch_sha256, baseCommit: body?.base_commit, sourceTaskId: body?.source_task_id ?? null });
911
+ return sendJson(res, queued.ok === true ? 200 : 400, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...queued });
912
+ }
913
+ if (method === 'POST' && queueId !== null && action === 'complete') {
914
+ const body = takeJson();
915
+ const settled = this.state.refs.complete({ queueId, outcome: body?.outcome, headCommit: body?.head_commit ?? null, conflictSha256: body?.conflict_sha256 ?? null });
916
+ return sendJson(res, settled.ok === true ? 200 : 409, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...settled });
917
+ }
918
+ throw new ProtocolError('BAD_REQUEST', 'merge queue accepts GET, POST enqueue or POST /:id/complete', { method, path });
919
+ }
920
+
921
+ /**
922
+ * v1.3.14-beta.1 (collaboration model): shared objects. Two clients in the same project read the SAME board and the SAME
923
+ * artifact index; before this they each filtered the global task list and disagreed as soon as one of them looked late.
924
+ */
925
+ const sharedMatch = /^\/v1\/projects\/([^/]+)\/(board|artifacts)$/.exec(path);
926
+ if (sharedMatch !== null) {
927
+ const projectId = decodeURIComponent(sharedMatch[1]);
928
+ const resource = sharedMatch[2];
929
+ const readDevice = () => {
930
+ const token = bearerToken(req);
931
+ const caller = token === null ? null : this.state.authenticate(token);
932
+ if (caller === null) throw new ProtocolError('UNAUTHORIZED', 'reading a project object needs a paired device token');
933
+ return caller;
934
+ };
935
+ if (method === 'GET') {
936
+ const caller = readDevice();
937
+ const snapshot = this.state.projectSnapshot(projectId);
938
+ const isDefault = snapshot.project.project_id === this.state.defaultProjectId;
939
+ if (!isDefault) {
940
+ const member = this.state.projects.memberOf({ projectId, member: { kind: 'machine', id: caller.machine_id } });
941
+ if (member === null) throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: projectId });
942
+ }
943
+ if (resource === 'board') return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...this.state.projectBoard({ projectId }) });
944
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, artifacts: this.state.listArtifacts({ projectId }) });
945
+ }
946
+ if (method === 'POST' && resource === 'artifacts') {
947
+ const auth = this.authorizeTaskDispatch(req);
948
+ if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'registering a project artifact is an operator act');
949
+ const body = takeJson();
950
+ const registered = this.state.registerArtifact({ projectId, sha256: body?.sha256, bytes: body?.bytes, kind: body?.kind ?? 'artifact', sourceTaskId: body?.source_task_id ?? null });
951
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...registered });
952
+ }
953
+ throw new ProtocolError('BAD_REQUEST', 'project objects accept GET, or POST artifacts', { method, path });
954
+ }
955
+
956
+ /**
957
+ * v1.3.14-beta.2 (collaboration model): decisions and project memory as shared objects. The board answers "what is
958
+ * happening", these answer "what did we agree" -- and both are one object per project, not a per-client copy.
959
+ */
960
+ const knowledgeMatch = /^\/v1\/projects\/([^/]+)\/(decisions|memory)$/.exec(path);
961
+ if (knowledgeMatch !== null) {
962
+ const projectId = decodeURIComponent(knowledgeMatch[1]);
963
+ const resource = knowledgeMatch[2];
964
+ const readDevice = () => {
965
+ const token = bearerToken(req);
966
+ const caller = token === null ? null : this.state.authenticate(token);
967
+ if (caller === null) throw new ProtocolError('UNAUTHORIZED', 'reading a project object needs a paired device token');
968
+ return caller;
969
+ };
970
+ const requireReadable = (caller) => {
971
+ const snapshot = this.state.projectSnapshot(projectId);
972
+ if (snapshot.project.project_id === this.state.defaultProjectId) return;
973
+ const member = this.state.projects.memberOf({ projectId, member: { kind: 'machine', id: caller.machine_id } });
974
+ if (member === null) throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: projectId });
975
+ };
976
+ if (method === 'GET') {
977
+ const caller = readDevice();
978
+ requireReadable(caller);
979
+ if (resource === 'decisions') return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, decisions: this.state.listDecisions({ projectId }) });
980
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, document: this.state.getProjectMemory({ projectId }) });
981
+ }
982
+ const auth = this.authorizeTaskDispatch(req);
983
+ if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'writing shared project objects is an operator act');
984
+ const body = takeJson();
985
+ if (method === 'POST' && resource === 'decisions') {
986
+ const added = this.state.addDecision({ projectId, title: body?.title, text: body?.text ?? null, actor: { kind: 'operator', id: 'operator' }, refs: body?.refs ?? {}, kind: body?.kind ?? 'decision' });
987
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...added });
988
+ }
989
+ if (method === 'PUT' && resource === 'memory') {
990
+ const updated = this.state.setProjectMemory({ projectId, text: body?.text, actor: { kind: 'operator', id: 'operator' } });
991
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...updated });
992
+ }
993
+ throw new ProtocolError('BAD_REQUEST', 'decisions accept GET/POST, memory accepts GET/PUT', { method, path });
994
+ }
995
+
996
+ /**
997
+ * v1.3.14-beta.3 (collaboration model): notifications for shared-object events. The rows are broadcast to project
998
+ * members, read with a per-reader watermark, and deduplicated inside the notifications module; a client that reconnects
999
+ * asks for everything after its watermark instead of re-reading the whole event stream.
1000
+ */
1001
+ const notifyMatch = /^\/v1\/projects\/([^/]+)\/notifications(?:\/(unread|read))?$/.exec(path);
1002
+ if (notifyMatch !== null) {
1003
+ const projectId = decodeURIComponent(notifyMatch[1]);
1004
+ const action = notifyMatch[2] ?? null;
1005
+ const token = bearerToken(req);
1006
+ const caller = token === null ? null : this.state.authenticate(token);
1007
+ if (caller === null) throw new ProtocolError('UNAUTHORIZED', 'notifications need a paired device token');
1008
+ const snapshot = this.state.projectSnapshot(projectId);
1009
+ if (snapshot.project.project_id !== this.state.defaultProjectId) {
1010
+ const member = this.state.projects.memberOf({ projectId, member: { kind: 'machine', id: caller.machine_id } });
1011
+ if (member === null) throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: projectId });
1012
+ }
1013
+ const reader = { kind: 'machine', id: caller.machine_id };
1014
+ if (method === 'GET' && action === null) {
1015
+ const url = new URL(req.url, 'http://relay.invalid');
1016
+ const sinceMs = Number(url.searchParams.get('since_ms') ?? 0);
1017
+ const limit = Number(url.searchParams.get('limit') ?? 200);
1018
+ const listed = this.state.notifications.list({ projectId, sinceMs: Number.isFinite(sinceMs) ? sinceMs : 0, limit: Number.isFinite(limit) ? Math.max(1, Math.min(500, limit)) : 200 });
1019
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...listed });
1020
+ }
1021
+ if (method === 'GET' && action === 'unread') return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...this.state.notifications.unread({ projectId, reader }) });
1022
+ if (method === 'POST' && action === 'read') {
1023
+ const body = takeJson();
1024
+ const marked = this.state.notifications.markRead({ projectId, reader, watermarkMs: body?.watermark_ms });
1025
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...marked });
1026
+ }
1027
+ throw new ProtocolError('BAD_REQUEST', 'notifications accept GET, GET /unread or POST /read', { method, path });
1028
+ }
1029
+
1030
+ /**
1031
+ * v1.3.14-beta.4 (collaboration model): a project-scoped SSE stream.
1032
+ *
1033
+ * `ready` names the project and its last event seq, `since_seq` replays the room events a client missed, then every new
1034
+ * room event is pushed as `project.event`. Clients in the same project therefore apply the same ordered events instead
1035
+ * of each polling a different view.
1036
+ */
1037
+ const projectStreamMatch = /^\/v1\/projects\/([^/]+)\/stream$/.exec(path);
1038
+ if (projectStreamMatch !== null && method === 'GET') {
1039
+ const projectId = decodeURIComponent(projectStreamMatch[1]);
1040
+ const token = bearerToken(req);
1041
+ const caller = token === null ? null : this.state.authenticate(token);
1042
+ if (caller === null) throw new ProtocolError('UNAUTHORIZED', 'project events need a paired device token');
1043
+ const snapshot = this.state.projectSnapshot(projectId);
1044
+ if (snapshot.project.project_id !== this.state.defaultProjectId) {
1045
+ const member = this.state.projects.memberOf({ projectId, member: { kind: 'machine', id: caller.machine_id } });
1046
+ if (member === null) throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: projectId });
1047
+ }
1048
+ const sinceRaw = url.searchParams.get('since_seq');
1049
+ const sinceSeq = sinceRaw !== null && /^[0-9]+$/.test(sinceRaw) ? Number(sinceRaw) : 0;
1050
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
1051
+ const frame = (entry) => {
1052
+ try { res.write('id: ' + entry.seq + '\nevent: project.event\ndata: ' + JSON.stringify({ project_id: entry.project_id, seq: entry.seq, type: entry.type, payload: entry.payload }) + '\n\n'); } catch { /* closed */ }
1053
+ };
1054
+ const replayed = this.state.projects.events({ projectId, sinceSeq });
1055
+ res.write('event: ready\ndata: ' + JSON.stringify({ protocol_version: PROTOCOL_VERSION, project_id: projectId, last_event_seq: replayed.next_seq }) + '\n\n');
1056
+ for (const entry of replayed.events) frame(entry);
1057
+ const unsubscribe = this.state.onProjectEvent((entry) => { if (entry.project_id === projectId) frame(entry); });
1058
+ const keepalive = setInterval(() => { try { res.write(': keepalive\n\n'); } catch { /* closed */ } }, this.keepaliveMs);
1059
+ let closed = false;
1060
+ const done = () => { if (closed) return; closed = true; clearInterval(keepalive); unsubscribe(); try { res.end(); } catch { /* already closed */ } };
1061
+ req.on('close', done);
1062
+ res.on('close', done);
1063
+ return true;
1064
+ }
1065
+
814
1066
  /* ---- everything below requires `Authorization: Bearer <device_token>` ---- */
815
1067
  const token = bearerToken(req);
816
1068
  const device = token ? this.state.authenticate(token) : null;
@@ -819,12 +1071,92 @@ export class RelayServer {
819
1071
  }
820
1072
  this.state.touchDevice(device.machine_id);
821
1073
 
1074
+ const projectLockMatch = /^\/v1\/projects\/([^/]+)\/locks(?:\/([^/]+)(?:\/(renew))?)?$/.exec(path);
1075
+ if (projectLockMatch !== null && method !== undefined) {
1076
+ const projectId = decodeURIComponent(projectLockMatch[1]);
1077
+ const lockId = projectLockMatch[2] === undefined ? null : decodeURIComponent(projectLockMatch[2]);
1078
+ const action = projectLockMatch[3] ?? null;
1079
+ const roomMember = { kind: 'machine', id: device.machine_id };
1080
+ const snapshot = this.state.projectSnapshot(projectId);
1081
+ const isDefault = snapshot.project.project_id === this.state.defaultProjectId;
1082
+ const membership = isDefault ? { role: 'worker' } : this.state.projects.memberOf({ projectId, member: roomMember });
1083
+ 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' } });
1084
+ const roleRank = { observer: 0, worker: 1, maintainer: 2, owner: 3 };
1085
+ const canWrite = roleRank[membership.role] >= roleRank.worker;
1086
+ if (method === 'GET' && lockId === null) {
1087
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, locks: this.state.locks.list({ projectId }) });
1088
+ }
1089
+ if (method === 'POST' && lockId === null) {
1090
+ 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 } });
1091
+ const body = takeJson();
1092
+ 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 });
1093
+ if (acquired.ok === true) this.state.metrics?.inc('wm_lock_acquired_total', { kind: String(body?.kind ?? '') });
1094
+ else if (acquired.code === 'LOCK_HELD') this.state.metrics?.inc('wm_lock_conflict_total', { kind: String(body?.kind ?? '') });
1095
+ return sendJson(res, acquired.ok ? 200 : 409, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...acquired });
1096
+ }
1097
+ if (method === 'POST' && lockId !== null && action === 'renew') {
1098
+ const renewed = this.state.locks.renew({ projectId, lockId, holder: { kind: "machine", id: device.machine_id } });
1099
+ return sendJson(res, renewed.ok ? 200 : 409, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...renewed });
1100
+ }
1101
+ if (method === 'DELETE' && lockId !== null) {
1102
+ const force = url.searchParams.get('force') === '1';
1103
+ const reason = url.searchParams.get('reason');
1104
+ const released = force
1105
+ ? this.state.locks.forceRelease({ projectId, lockId, actor: { kind: "machine", id: device.machine_id }, actorRole: membership.role, reason })
1106
+ : this.state.locks.release({ projectId, lockId, holder: { kind: "machine", id: device.machine_id } });
1107
+ return sendJson(res, released.ok ? 200 : 409, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...released });
1108
+ }
1109
+ return sendJson(res, 405, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'unsupported lock operation' } });
1110
+ }
1111
+
1112
+ /**
1113
+ * v1.3.10-beta.14: project-room reads (M4). Below the device guard, so the device is already authenticated.
1114
+ *
1115
+ * A machine sees the default project and the projects it is a member of; a project it is not a member of answers
1116
+ * NOT_FOUND, so a project existence is not leaked. The snapshot carries last_event_seq, and the events route returns
1117
+ * the slice after since_seq, which is what lets a client resume after a disconnect.
1118
+ */
1119
+ if (method === 'GET' && (path === '/v1/projects' || path.startsWith('/v1/projects/'))) {
1120
+ const match = /^\/v1\/projects(?:\/([^/]+))?(?:\/(events|members))?$/.exec(path);
1121
+ if (match === null) throw new ProtocolError('NOT_FOUND', 'unknown route', { path });
1122
+ const projectId = match[1] === undefined ? null : decodeURIComponent(match[1]);
1123
+ const sub = match[2] ?? null;
1124
+ const readSnapshot = (id) => {
1125
+ let snapshot;
1126
+ try {
1127
+ snapshot = this.state.projectSnapshot(id);
1128
+ } catch (error) {
1129
+ if (error?.code === 'BAD_REQUEST' || error?.code === 'PROJECT_NOT_FOUND') throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: id });
1130
+ throw error;
1131
+ }
1132
+ if (snapshot.project.project_id === this.state.defaultProjectId) return snapshot;
1133
+ const member = this.state.projects.memberOf({ projectId: id, member: { kind: 'machine', id: device.machine_id } });
1134
+ if (member === null) throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: id });
1135
+ return snapshot;
1136
+ };
1137
+ if (projectId === null) {
1138
+ const mine = this.state.projects.listProjects({ member: { kind: 'machine', id: device.machine_id } }).projects;
1139
+ if (!mine.some((project) => project.project_id === this.state.defaultProjectId)) mine.unshift({ project_id: this.state.defaultProjectId, name: 'default', origin: null });
1140
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, projects: mine });
1141
+ }
1142
+ if (sub === null) return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...readSnapshot(projectId) });
1143
+ if (sub === 'events') {
1144
+ readSnapshot(projectId);
1145
+ const raw = url.searchParams.get('since_seq');
1146
+ const sinceSeq = raw !== null && /^[0-9]+$/.test(raw) ? Number(raw) : 0;
1147
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...this.state.projectEvents(projectId, { sinceSeq }) });
1148
+ }
1149
+ throw new ProtocolError('BAD_REQUEST', 'unsupported project route', { method, path });
1150
+ }
1151
+
1152
+
822
1153
  // v0.3.0 §9: writes are always checked (tolerating unsigned unless required);
823
1154
  // reads are only checked when the relay was explicitly told to require
824
1155
  // signatures. See `enforceSignature` for the reasoning.
825
1156
  this.enforceSignature(req, { method, signedPath, body: rawBody });
826
1157
 
827
1158
  /* ---- v1.2.5: members and read watermarks ---- */
1159
+
828
1160
  if (method === 'GET' && path === '/v1/fleet/members') {
829
1161
  return sendJson(res, 200, {
830
1162
  protocol_version: PROTOCOL_VERSION,
@@ -871,6 +1203,38 @@ export class RelayServer {
871
1203
  const { _instanceKey, ...clean } = result;
872
1204
  return sendJson(res, 200, clean);
873
1205
  }
1206
+ /**
1207
+ * v1.3.10-beta.20: chunked blob sessions (M6). A >64 KiB artifact is uploaded in idempotent chunks and only becomes a
1208
+ * blob when the assembled bytes hash to the declared sha256; the session answers "which chunks are missing" so an
1209
+ * interrupted upload resumes instead of restarting.
1210
+ */
1211
+ const blobSessionCreate = method === 'POST' && path === '/v1/fleet/blob-sessions';
1212
+ const blobSessionChunk = /^\/v1\/fleet\/blob-sessions\/([^/]+)\/chunks\/([0-9]+)$/.exec(path);
1213
+ const blobSessionCommit = /^\/v1\/fleet\/blob-sessions\/([^/]+)\/commit$/.exec(path);
1214
+ const blobSessionStatus = /^\/v1\/fleet\/blob-sessions\/([^/]+)$/.exec(path);
1215
+ if (blobSessionCreate || blobSessionChunk !== null || blobSessionCommit !== null || blobSessionStatus !== null) {
1216
+ 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; } };
1217
+ if (blobSessionCreate) {
1218
+ const body = takeJson();
1219
+ const created = wrap(() => this.state.blobSessions.create({ machineId: device.machine_id, sha256: body?.sha256, size: body?.size, chunkBytes: body?.chunk_bytes }));
1220
+ return sendJson(res, 201, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...created });
1221
+ }
1222
+ if (method === 'PUT' && blobSessionChunk !== null) {
1223
+ const data = rawBody ?? Buffer.alloc(0);
1224
+ const stored = wrap(() => this.state.blobSessions.putChunk({ sessionId: decodeURIComponent(blobSessionChunk[1]), index: Number(blobSessionChunk[2]), data }));
1225
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...stored });
1226
+ }
1227
+ if (method === 'POST' && blobSessionCommit !== null) {
1228
+ const committed = wrap(() => this.state.blobSessions.commit({ sessionId: decodeURIComponent(blobSessionCommit[1]) }));
1229
+ return sendJson(res, committed.ok === true ? 201 : 409, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...committed });
1230
+ }
1231
+ if (method === 'GET' && blobSessionStatus !== null) {
1232
+ const status = wrap(() => this.state.blobSessions.status({ sessionId: decodeURIComponent(blobSessionStatus[1]) }));
1233
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...status });
1234
+ }
1235
+ throw new ProtocolError('BAD_REQUEST', 'a blob session is created with POST, chunks with PUT, committed with POST', {});
1236
+ }
1237
+
874
1238
  /**
875
1239
  * v1.0.7: the content-addressed blob channel (#1).
876
1240
  *
@@ -956,6 +1320,23 @@ export class RelayServer {
956
1320
  // Same code and shape for "no such blob" and "not your fleet's" -- deliberately indistinguishable.
957
1321
  throw new ProtocolError('NOT_FOUND', 'no such blob is readable by this device', { hash });
958
1322
  }
1323
+ const rangeParam = url.searchParams.get('range');
1324
+ if (rangeParam !== null) {
1325
+ const match = /^([0-9]+)-([0-9]+)$/.exec(rangeParam);
1326
+ if (match === null) throw new ProtocolError('BAD_REQUEST', 'range must be start-end', { range: rangeParam });
1327
+ const start = Number(match[1]);
1328
+ const end = Math.min(Number(match[2]), bytes.length - 1);
1329
+ if (!(start >= 0 && start <= end && start < bytes.length)) throw new ProtocolError('BAD_REQUEST', 'range is outside the blob', { range: rangeParam, bytes: bytes.length });
1330
+ const slice = bytes.subarray(start, end + 1);
1331
+ res.writeHead(206, {
1332
+ 'content-type': 'application/octet-stream',
1333
+ 'content-length': slice.length,
1334
+ 'content-range': 'bytes ' + start + '-' + end + '/' + bytes.length,
1335
+ 'cache-control': 'no-store',
1336
+ });
1337
+ res.end(slice);
1338
+ return true;
1339
+ }
959
1340
  res.writeHead(200, {
960
1341
  'content-type': 'application/octet-stream',
961
1342
  'content-length': bytes.length,