@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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +588 -0
- package/bin/w2m-maintenance.mjs +112 -0
- package/codex/mcp-servers.toml +1 -1
- package/deploy/maintenance/bootstrap.ps1 +30 -0
- package/deploy/maintenance/bootstrap.sh +59 -0
- package/deploy/server/w2m-maintenance.path +10 -0
- package/deploy/server/w2m-maintenance.service +20 -0
- package/deploy/windows/w2m-maintenance.xml +31 -0
- package/docs/COLLAB/COLLABORATION-MODEL.md +240 -0
- package/docs/COLLAB/EXECUTION-AUTH-MODEL.md +250 -0
- package/docs/COLLAB/M1-ACCEPTANCE-RUNBOOK.md +70 -0
- package/docs/COLLAB/M1-MAINTENANCE.md +313 -0
- package/docs/COLLAB/M2-TASK-GRANT.md +231 -0
- package/docs/COLLAB/M3-GIT-NATIVE-TASKS.md +291 -0
- package/docs/COLLAB/M4-PROJECT-ROOM.md +260 -0
- package/docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md +263 -0
- package/docs/COLLAB/M6-CROSS-NETWORK-RELIABILITY.md +231 -0
- package/docs/COLLAB/M7-METRICS-ACCEPTANCE.md +263 -0
- package/docs/COLLAB/TASK-MODEL.md +281 -0
- package/docs/MULTI-MACHINE-COLLABORATION-PLAN.md +186 -0
- package/docs/RELEASE-STABLE.md +193 -0
- package/docs/acceptance/1.3.10-beta.10/M1-LIVE-DIAG.json +27 -0
- package/docs/acceptance/1.3.10-beta.10/M1-LIVE-POSTUPDATE-DIAG.json +18 -0
- package/docs/acceptance/1.3.10-beta.10/M1-LIVE-RESTART.json +24 -0
- package/docs/acceptance/1.3.10-beta.10/M1-LIVE-UPDATE.json +24 -0
- package/docs/acceptance/1.3.11/M1-LIVE-DIAG.json +26 -0
- package/package.json +22 -4
- package/plugin.json +1 -1
- package/src/acceptance/evidence.mjs +79 -0
- package/src/agent/agent.mjs +64 -2
- package/src/agent/grant-gate.mjs +82 -0
- package/src/agent/maintenance-ferry.mjs +111 -0
- package/src/agent/outbox.mjs +138 -0
- package/src/agent/patch-artifact.mjs +53 -0
- package/src/agent/worktree.mjs +109 -0
- package/src/maintenance/actions.mjs +203 -0
- package/src/maintenance/grant.mjs +139 -0
- package/src/maintenance/operator-key.mjs +151 -0
- package/src/maintenance/protocol.mjs +204 -0
- package/src/maintenance/runner.mjs +189 -0
- package/src/plugin/tools.mjs +1 -1
- package/src/relay/blob-sessions.mjs +148 -0
- package/src/relay/merge-queue.mjs +137 -0
- package/src/relay/project-locks.mjs +179 -0
- package/src/relay/project-notifications.mjs +182 -0
- package/src/relay/project-reviews.mjs +161 -0
- package/src/relay/project-room.mjs +163 -0
- package/src/relay/reliability-metrics.mjs +180 -0
- package/src/relay/server.mjs +164 -1
- package/src/relay/state.mjs +128 -1
package/src/relay/server.mjs
CHANGED
|
@@ -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
|
|
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,
|
package/src/relay/state.mjs
CHANGED
|
@@ -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
|
-
|
|
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()];
|