@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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +709 -0
- package/bin/w2m-grant.mjs +98 -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 +61 -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 +361 -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/docs/acceptance/1.3.12-beta.1/M1-LIVE-DIAG.json +26 -0
- package/docs/acceptance/1.4.0/M1-LIVE-MAINTENANCE.json +20 -0
- package/docs/acceptance/1.4.0/REGRESSION-1.4.0.json +81 -0
- package/package.json +26 -5
- package/plugin.json +1 -1
- package/src/acceptance/evidence.mjs +79 -0
- package/src/agent/agent.mjs +141 -3
- package/src/agent/grant-gate.mjs +89 -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 +118 -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 +184 -0
- package/src/relay/project-refs.mjs +110 -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 +382 -1
- package/src/relay/state.mjs +308 -1
package/src/relay/state.mjs
CHANGED
|
@@ -25,6 +25,14 @@ 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 { createProjectRefs } from './project-refs.mjs';
|
|
34
|
+
import { createMetrics } from './reliability-metrics.mjs';
|
|
35
|
+
import { argvHash as grantArgvHash } from '../maintenance/grant.mjs';
|
|
28
36
|
|
|
29
37
|
export const PROTOCOL_VERSION = 1;
|
|
30
38
|
|
|
@@ -37,6 +45,11 @@ export const PROTOCOL_VERSION = 1;
|
|
|
37
45
|
export const ERROR_STATUS = Object.freeze({
|
|
38
46
|
UNAUTHORIZED: 401,
|
|
39
47
|
BAD_REQUEST: 400,
|
|
48
|
+
// v1.3.12-beta.1: dispatch refuses a grant written for another task or argv. Caller errors (400), not authorization verdicts.
|
|
49
|
+
GRANT_TASK_MISMATCH: 400,
|
|
50
|
+
GRANT_ARGV_MISMATCH: 400,
|
|
51
|
+
GRANT_EXPIRED: 400,
|
|
52
|
+
GRANT_REVOKED: 403,
|
|
40
53
|
NOT_FOUND: 404,
|
|
41
54
|
PAIRING_INVALID: 400,
|
|
42
55
|
PAIRING_EXPIRED: 400,
|
|
@@ -509,6 +522,71 @@ export class RabbitState {
|
|
|
509
522
|
/** @type {{seq:number,event:object,target:string|null}[]} ring buffer, last N events */
|
|
510
523
|
this.buffer = [];
|
|
511
524
|
this.devices = new Map(); // machine_id -> device
|
|
525
|
+
// v1.3.10-beta.2: operator signing keys seen with a dispatch; agents pin the first one they receive.
|
|
526
|
+
this.operatorKeys = new Map();
|
|
527
|
+
// v1.3.10-beta.13: shared project objects (M4). Tasks name a project; readers get snapshot + events.
|
|
528
|
+
this.metrics = createMetrics();
|
|
529
|
+
// v1.3.14-beta.4: project-scoped live push. A client that follows one project subscribes here instead of filtering the
|
|
530
|
+
// global stream, so two clients see the same project events in the same order (the room owns the sequence).
|
|
531
|
+
this.projectSubscribers = new Set();
|
|
532
|
+
// v1.3.14-beta.1 (collaboration model): shared objects every client reads the same way -- a project board and an artifact
|
|
533
|
+
// index. Before this a client could only list ALL tasks, so two clients "in the same project" built different boards.
|
|
534
|
+
this.artifacts = new Map();
|
|
535
|
+
// v1.3.14-beta.2: the remaining shared objects -- decisions and a project memory document. Both are append/versioned and
|
|
536
|
+
// read by every member, so "what we agreed" is the same object for every client rather than a chat history.
|
|
537
|
+
this.decisions = new Map();
|
|
538
|
+
this.projectMemory = new Map();
|
|
539
|
+
// v1.3.13-beta.4: branch heads and merge outcomes live with the project room so one stream answers "where is the branch".
|
|
540
|
+
this.refs = createProjectRefs({
|
|
541
|
+
now: () => this.nowMs(),
|
|
542
|
+
onEvent: (event) => { try { this.projects.appendEvent({ projectId: event.payload.project_id, type: event.type, actor: { kind: 'operator', id: 'relay' }, payload: event.payload }); } catch { /* history */ } },
|
|
543
|
+
});
|
|
544
|
+
// Every project-room event is counted once, here: the room's own events and the lock/review/notification events projected
|
|
545
|
+
// into it both pass through this callback, so the counter cannot double-count a projection.
|
|
546
|
+
// v1.3.14-beta.3: which project events are worth a notification, and whether they need an action from a member.
|
|
547
|
+
const NOTIFIED = Object.freeze({
|
|
548
|
+
'decision.recorded': { kind: 'decision.recorded', subject: 'a decision was recorded', requiresAction: false },
|
|
549
|
+
'artifact.registered': { kind: 'artifact.registered', subject: 'an artifact was registered', requiresAction: false },
|
|
550
|
+
'memory.updated': { kind: 'memory.updated', subject: 'project memory was updated', requiresAction: false },
|
|
551
|
+
'merge.conflict': { kind: 'merge.conflict', subject: 'a merge conflicted', requiresAction: true },
|
|
552
|
+
'merge.merged': { kind: 'merge.merged', subject: 'a patch was merged', requiresAction: false },
|
|
553
|
+
'ref.advanced': { kind: 'ref.updated', subject: 'a branch was advanced', requiresAction: false },
|
|
554
|
+
});
|
|
555
|
+
const metricsEvent = (event) => {
|
|
556
|
+
try { this.metrics.inc('wm_project_events_total', { type: event.type }); } catch { /* metrics never break the room */ }
|
|
557
|
+
for (const listener of this.projectSubscribers) { try { listener(event); } catch { /* a subscriber never breaks the room */ } }
|
|
558
|
+
const notified = NOTIFIED[event.type];
|
|
559
|
+
if (notified !== undefined) {
|
|
560
|
+
try {
|
|
561
|
+
this.notifications.notify({ projectId: event.project_id, kind: notified.kind, target: { kind: 'broadcast' }, subject: notified.subject, refs: event.payload ?? {}, requiresAction: notified.requiresAction });
|
|
562
|
+
} catch { /* a notification is never worth failing the event for */ }
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
this.projects = createProjectRoom({ now: () => this.nowMs(), onEvent: metricsEvent });
|
|
566
|
+
this.defaultProjectId = 'default';
|
|
567
|
+
// M5 primitives share the project room's event stream: a lock, review or notification is a project event with the
|
|
568
|
+
// module's own sequence attached, so one reader can reconstruct "what happened" from a single cursor.
|
|
569
|
+
const roomEvent = (event) => {
|
|
570
|
+
try {
|
|
571
|
+
this.projects.appendEvent({ projectId: event.payload.project_id, type: event.type, actor: { kind: 'operator', id: 'relay' }, payload: { ...event.payload, module_seq: event.seq } });
|
|
572
|
+
} catch { /* an event about a project that is gone is history, not an error */ }
|
|
573
|
+
};
|
|
574
|
+
this.locks = createProjectLocks({ now: () => this.nowMs(), onEvent: roomEvent });
|
|
575
|
+
this.reviews = createProjectReviews({ now: () => this.nowMs(), onEvent: roomEvent });
|
|
576
|
+
this.notifications = createProjectNotifications({ now: () => this.nowMs(), onEvent: roomEvent });
|
|
577
|
+
// v1.3.10-beta.20: a committed chunk session becomes a fleet blob with the same verified bytes. A store failure does not
|
|
578
|
+
// undo the commit (the session is gone), but it is reported through the route so a caller never believes a blob exists.
|
|
579
|
+
// v1.3.12-beta.2: revoked grants. The operator can stop one authorisation from being used; the dispatch preflight
|
|
580
|
+
// refuses a task carrying a revoked grant, and the ledger keeps the revocation with its reason.
|
|
581
|
+
this.revokedGrants = new Map();
|
|
582
|
+
this.blobSessions = createBlobSessions({
|
|
583
|
+
now: () => this.nowMs(),
|
|
584
|
+
onCommit: (blob) => {
|
|
585
|
+
const device = this.devices.get(blob.machineId);
|
|
586
|
+
if (device === undefined) return;
|
|
587
|
+
this.putFleetBlob(device, blob.bytes);
|
|
588
|
+
},
|
|
589
|
+
});
|
|
512
590
|
/** v1.2.5: operator-assigned roles, kept apart from device records so a re-pair cannot change authority. */
|
|
513
591
|
this.roles = new Map(); // machine_id -> 'owner'|'worker'|'observer'
|
|
514
592
|
/** v1.2.5: read watermarks per session, per machine. */
|
|
@@ -705,6 +783,10 @@ export class RabbitState {
|
|
|
705
783
|
|
|
706
784
|
/** @returns {boolean} true when a new task was created. */
|
|
707
785
|
_applyLedgerEntry(entry) {
|
|
786
|
+
if (entry?.type === 'operator.key.registered' && entry.key && typeof entry.key.key_id === 'string') {
|
|
787
|
+
this.operatorKeys.set(entry.key.key_id, { ...entry.key });
|
|
788
|
+
return false;
|
|
789
|
+
}
|
|
708
790
|
if (!entry || typeof entry.type !== 'string') return false;
|
|
709
791
|
switch (entry.type) {
|
|
710
792
|
case 'task.created': {
|
|
@@ -738,7 +820,8 @@ export class RabbitState {
|
|
|
738
820
|
// v1.2.7: a revived task keeps its graph edges and assignment, or the scheduler silently becomes a different scheduler.
|
|
739
821
|
depends_on: Array.isArray(t.depends_on) ? t.depends_on : [],
|
|
740
822
|
assignee: typeof t.assignee === 'string' && t.assignee !== '' ? t.assignee : null,
|
|
741
|
-
|
|
823
|
+
project_id: typeof t.project_id === 'string' ? t.project_id : null,
|
|
824
|
+
reduce_of: typeof t.reduce_of === 'string' ? t.reduce_of : null,
|
|
742
825
|
reduce_inputs: Array.isArray(t.reduce_inputs) ? t.reduce_inputs : [],
|
|
743
826
|
leases: new Map(),
|
|
744
827
|
cancelled: false,
|
|
@@ -1730,6 +1813,85 @@ export class RabbitState {
|
|
|
1730
1813
|
* v1.2.10: validate the reduce artifact list up front. A malformed list would otherwise become an offer the machine
|
|
1731
1814
|
* cannot execute, and the failure would surface as a generic refusal on the executor instead of at dispatch.
|
|
1732
1815
|
*/
|
|
1816
|
+
/**
|
|
1817
|
+
* v1.3.10-beta.2: a maintenance request is opaque to the relay (the root runner is the authority) but its shape and
|
|
1818
|
+
* size are checked here so a malformed request is refused at dispatch instead of on a machine it was never meant for.
|
|
1819
|
+
*/
|
|
1820
|
+
const rawMaintenance = input.maintenance ?? null;
|
|
1821
|
+
let normalizedMaintenance = null;
|
|
1822
|
+
if (rawMaintenance !== null) {
|
|
1823
|
+
if (typeof rawMaintenance !== 'object' || Array.isArray(rawMaintenance) || Buffer.byteLength(JSON.stringify(rawMaintenance), 'utf8') > 64 * 1024) {
|
|
1824
|
+
throw new ProtocolError('BAD_REQUEST', 'maintenance must be an object of at most 64 KiB', {});
|
|
1825
|
+
}
|
|
1826
|
+
if (typeof rawMaintenance.request_id !== 'string' || rawMaintenance.request_id === '') {
|
|
1827
|
+
throw new ProtocolError('BAD_REQUEST', 'maintenance.request_id is required', {});
|
|
1828
|
+
}
|
|
1829
|
+
normalizedMaintenance = rawMaintenance;
|
|
1830
|
+
}
|
|
1831
|
+
/**
|
|
1832
|
+
* v1.3.10-beta.3: a task-level grant travels with the dispatch. The relay checks its shape and time window so a bad
|
|
1833
|
+
* grant is refused at dispatch, but the agent is the verifier — the relay never decides authorisation on its own.
|
|
1834
|
+
*/
|
|
1835
|
+
/**
|
|
1836
|
+
* v1.3.13-beta.1 (task model): a task may explain WHICH copy of the project it runs on. The relay validates the
|
|
1837
|
+
* descriptor and carries it, exactly as it carries argv: the machine that owns the copy decides whether it can honour
|
|
1838
|
+
* it (and the agent currently refuses a workspace task with WORKSPACE_UNSUPPORTED until the execution slice lands).
|
|
1839
|
+
*/
|
|
1840
|
+
const rawWorkspace = input.workspace ?? null;
|
|
1841
|
+
let normalizedWorkspace = null;
|
|
1842
|
+
if (rawWorkspace !== null) {
|
|
1843
|
+
if (typeof rawWorkspace !== 'object' || Array.isArray(rawWorkspace) || Buffer.byteLength(JSON.stringify(rawWorkspace), 'utf8') > 4 * 1024) {
|
|
1844
|
+
throw new ProtocolError('BAD_REQUEST', 'workspace must be an object of at most 4 KiB', {});
|
|
1845
|
+
}
|
|
1846
|
+
if (rawWorkspace.kind !== 'worktree') throw new ProtocolError('BAD_REQUEST', "workspace.kind must be 'worktree'", { kind: rawWorkspace.kind });
|
|
1847
|
+
if (typeof rawWorkspace.base_commit !== 'string' || !/^[0-9a-f]{7,64}$/.test(rawWorkspace.base_commit)) {
|
|
1848
|
+
throw new ProtocolError('BAD_REQUEST', 'workspace.base_commit must be a hex commit id', { base_commit: rawWorkspace.base_commit });
|
|
1849
|
+
}
|
|
1850
|
+
const produce = rawWorkspace.produce ?? 'patch';
|
|
1851
|
+
if (!['patch', 'commit', 'none'].includes(produce)) throw new ProtocolError('BAD_REQUEST', "workspace.produce must be patch, commit or none", { produce });
|
|
1852
|
+
normalizedWorkspace = { kind: 'worktree', base_commit: rawWorkspace.base_commit, produce };
|
|
1853
|
+
}
|
|
1854
|
+
const rawGrant = input.grant ?? null;
|
|
1855
|
+
let normalizedGrant = null;
|
|
1856
|
+
if (rawGrant !== null) {
|
|
1857
|
+
if (typeof rawGrant !== 'object' || Array.isArray(rawGrant) || Buffer.byteLength(JSON.stringify(rawGrant), 'utf8') > 8 * 1024) {
|
|
1858
|
+
throw new ProtocolError('BAD_REQUEST', 'grant must be an object of at most 8 KiB', {});
|
|
1859
|
+
}
|
|
1860
|
+
if (typeof rawGrant.grant_id !== 'string' || rawGrant.grant_id === '' || !/^[0-9a-f]{64}$/.test(String(rawGrant.argv_hash ?? ''))) {
|
|
1861
|
+
throw new ProtocolError('BAD_REQUEST', 'grant needs grant_id and a 64-hex argv_hash', {});
|
|
1862
|
+
}
|
|
1863
|
+
// v1.3.12-beta.1: dispatch-side preflight of the execution-authorization model. The agent verifies the signature and
|
|
1864
|
+
// the use ledger; the relay refuses a grant that is plainly for another task or another argv, by name, before any
|
|
1865
|
+
// machine sees the task. This is a caller-bug check, not a second authorisation decision.
|
|
1866
|
+
if (typeof rawGrant.task_id === 'string' && rawGrant.task_id !== '' && rawGrant.task_id !== taskId) {
|
|
1867
|
+
throw new ProtocolError('GRANT_TASK_MISMATCH', 'the grant names another task_id', { grant_task_id: rawGrant.task_id, task_id: taskId });
|
|
1868
|
+
}
|
|
1869
|
+
if (grantArgvHash(commandArgv) !== String(rawGrant.argv_hash)) {
|
|
1870
|
+
throw new ProtocolError('GRANT_ARGV_MISMATCH', 'the grant argv_hash does not match command_argv', { argv_hash: String(rawGrant.argv_hash), computed: grantArgvHash(commandArgv) });
|
|
1871
|
+
}
|
|
1872
|
+
if (rawGrant.expires_at !== undefined) {
|
|
1873
|
+
const expiresAt = Date.parse(String(rawGrant.expires_at));
|
|
1874
|
+
if (Number.isNaN(expiresAt)) throw new ProtocolError('BAD_REQUEST', 'grant.expires_at is not a timestamp', { expires_at: rawGrant.expires_at });
|
|
1875
|
+
if (expiresAt <= this.nowMs()) throw new ProtocolError('GRANT_EXPIRED', 'the grant has already expired', { expires_at: rawGrant.expires_at });
|
|
1876
|
+
}
|
|
1877
|
+
if (this.revokedGrants.has(String(rawGrant.grant_id))) {
|
|
1878
|
+
throw new ProtocolError('GRANT_REVOKED', 'the grant was revoked by the operator', { grant_id: String(rawGrant.grant_id) });
|
|
1879
|
+
}
|
|
1880
|
+
normalizedGrant = rawGrant;
|
|
1881
|
+
}
|
|
1882
|
+
const rawOperatorKey = input.operator_key ?? null;
|
|
1883
|
+
let normalizedOperatorKey = null;
|
|
1884
|
+
if (rawOperatorKey !== null) {
|
|
1885
|
+
if (typeof rawOperatorKey !== 'object' || Array.isArray(rawOperatorKey)) throw new ProtocolError('BAD_REQUEST', 'operator_key must be an object', {});
|
|
1886
|
+
if (typeof rawOperatorKey.key_id !== 'string' || typeof rawOperatorKey.public_key_pem !== 'string') {
|
|
1887
|
+
throw new ProtocolError('BAD_REQUEST', 'operator_key needs key_id and public_key_pem', {});
|
|
1888
|
+
}
|
|
1889
|
+
normalizedOperatorKey = { key_id: rawOperatorKey.key_id, public_key_pem: rawOperatorKey.public_key_pem, created_at: rawOperatorKey.created_at ?? new Date(this.nowMs()).toISOString() };
|
|
1890
|
+
if (this.operatorKeys.has(normalizedOperatorKey.key_id) === false) {
|
|
1891
|
+
this.operatorKeys.set(normalizedOperatorKey.key_id, normalizedOperatorKey);
|
|
1892
|
+
this._ledger({ type: 'operator.key.registered', key: normalizedOperatorKey });
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1733
1895
|
const rawReduceInputs = input.reduce_inputs === undefined || input.reduce_inputs === null ? [] : input.reduce_inputs;
|
|
1734
1896
|
if (!Array.isArray(rawReduceInputs) || rawReduceInputs.length > 16) {
|
|
1735
1897
|
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 +1944,15 @@ export class RabbitState {
|
|
|
1782
1944
|
* v1.2.10: a reduce task names the map task it reduces and the artifacts it consumes. The artifact LIST travels with
|
|
1783
1945
|
* the task (never inferred by the reducer), because "which shards this reduce covers" is the completeness contract.
|
|
1784
1946
|
*/
|
|
1947
|
+
project_id: this.ensureProject(input.project_id ?? null),
|
|
1785
1948
|
reduce_of: typeof input.reduce_of === 'string' && input.reduce_of !== '' ? input.reduce_of : null,
|
|
1949
|
+
workspace: normalizedWorkspace,
|
|
1950
|
+
grant: normalizedGrant,
|
|
1951
|
+
grant_summary: normalizedGrant === null ? null : { grant_id: normalizedGrant.grant_id, argv_hash: normalizedGrant.argv_hash, key_id: normalizedGrant.signature?.key_id ?? null },
|
|
1952
|
+
/** v1.3.10-beta.2: a signed maintenance request travels with the task; the relay carries it, the root runner verifies it. */
|
|
1953
|
+
maintenance: normalizedMaintenance,
|
|
1954
|
+
operator_key: normalizedOperatorKey,
|
|
1955
|
+
maintenance_summary: normalizedMaintenance === null ? null : { request_id: normalizedMaintenance.request_id ?? null, action: normalizedMaintenance.action ?? null },
|
|
1786
1956
|
reduce_inputs: normalizedReduceInputs,
|
|
1787
1957
|
leases: new Map(),
|
|
1788
1958
|
cancelled: false,
|
|
@@ -2043,6 +2213,8 @@ export class RabbitState {
|
|
|
2043
2213
|
type: 'task.created',
|
|
2044
2214
|
task: {
|
|
2045
2215
|
task_id: task.task_id,
|
|
2216
|
+
workspace: task.workspace ?? null,
|
|
2217
|
+
project_id: task.project_id,
|
|
2046
2218
|
mode: task.mode,
|
|
2047
2219
|
command_argv: task.command_argv,
|
|
2048
2220
|
cwd_rel: task.cwd_rel,
|
|
@@ -2067,6 +2239,8 @@ export class RabbitState {
|
|
|
2067
2239
|
depends_on: Array.isArray(task.depends_on) ? task.depends_on : [],
|
|
2068
2240
|
assignee: task.assignee ?? null,
|
|
2069
2241
|
reduce_of: task.reduce_of ?? null,
|
|
2242
|
+
maintenance_summary: task.maintenance_summary ?? null,
|
|
2243
|
+
grant_summary: task.grant_summary ?? null,
|
|
2070
2244
|
reduce_inputs: Array.isArray(task.reduce_inputs) ? task.reduce_inputs : [],
|
|
2071
2245
|
origin_machine_id: task.origin_machine_id ?? null,
|
|
2072
2246
|
p2p: task.p2p ?? null,
|
|
@@ -2074,6 +2248,11 @@ export class RabbitState {
|
|
|
2074
2248
|
leases: [...task.leases.values()].map((l) => ({ ...l })),
|
|
2075
2249
|
});
|
|
2076
2250
|
|
|
2251
|
+
// v1.3.10-beta.13: a task belongs to a project, and the project room records the link as an event both clients see.
|
|
2252
|
+
try {
|
|
2253
|
+
this.projects.appendEvent({ projectId: task.project_id, type: 'task.linked', actor: { kind: 'operator', id: 'relay' }, payload: { task_id: taskId, mode: task.mode } });
|
|
2254
|
+
} catch { /* the ledger already carries the task; the room event is additive */ }
|
|
2255
|
+
|
|
2077
2256
|
const refusedOnly = [...task.leases.values()].every((l) => l.state === 'refused');
|
|
2078
2257
|
if (refusedOnly) {
|
|
2079
2258
|
// §6.3 Step 0 territory: a task nobody may execute is still a valid, reportable task.
|
|
@@ -2181,6 +2360,10 @@ export class RabbitState {
|
|
|
2181
2360
|
p2p: task.p2p ?? null,
|
|
2182
2361
|
// v1.2.10: present only on a reduce task, so a normal offer stays byte-identical.
|
|
2183
2362
|
...(task.reduce_of !== null ? { reduce_of: task.reduce_of, reduce_inputs: task.reduce_inputs } : {}),
|
|
2363
|
+
// v1.3.10-beta.2: the signed request and its operator key travel together; the agent pins the key on first sight.
|
|
2364
|
+
...(task.maintenance !== null && task.maintenance !== undefined ? { maintenance: task.maintenance, operator_key: task.operator_key } : {}),
|
|
2365
|
+
...(task.workspace !== null && task.workspace !== undefined ? { workspace: task.workspace } : {}),
|
|
2366
|
+
...(task.grant !== null && task.grant !== undefined ? { grant: task.grant } : {}),
|
|
2184
2367
|
cwd_rel: task.cwd_rel,
|
|
2185
2368
|
// Omitted when false, so a single-command offer for every existing caller stays byte-identical -- the
|
|
2186
2369
|
// same reason `stages` above is conditional.
|
|
@@ -2201,6 +2384,27 @@ export class RabbitState {
|
|
|
2201
2384
|
return event;
|
|
2202
2385
|
}
|
|
2203
2386
|
|
|
2387
|
+
/**
|
|
2388
|
+
* Resolve the project a task belongs to. The implicit default project is created lazily, so existing callers keep working
|
|
2389
|
+
* without naming one; an explicit unknown project is a refusal, not a new project.
|
|
2390
|
+
*/
|
|
2391
|
+
ensureProject(projectId) {
|
|
2392
|
+
const wanted = typeof projectId === 'string' && projectId !== '' ? projectId : this.defaultProjectId;
|
|
2393
|
+
try {
|
|
2394
|
+
this.projects.snapshot({ projectId: wanted });
|
|
2395
|
+
return wanted;
|
|
2396
|
+
} catch (error) {
|
|
2397
|
+
if (error?.code !== 'PROJECT_NOT_FOUND') throw error;
|
|
2398
|
+
if (wanted !== this.defaultProjectId) throw new ProtocolError('BAD_REQUEST', 'unknown project_id', { project_id: wanted });
|
|
2399
|
+
this.projects.createProject({ projectId: this.defaultProjectId, name: 'default', createdBy: { kind: 'operator', id: 'relay', name: 'relay' } });
|
|
2400
|
+
return wanted;
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
/** Snapshot helpers the project routes use; kept on state so the relay has one owner of the room. */
|
|
2405
|
+
projectSnapshot(projectId) { return this.projects.snapshot({ projectId: this.ensureProject(projectId) }); }
|
|
2406
|
+
projectEvents(projectId, options = {}) { return this.projects.events({ projectId: this.ensureProject(projectId), ...options }); }
|
|
2407
|
+
|
|
2204
2408
|
getTask(taskId) { return this.tasks.get(taskId) ?? null; }
|
|
2205
2409
|
|
|
2206
2410
|
requireTask(taskId) {
|
|
@@ -2320,6 +2524,103 @@ export class RabbitState {
|
|
|
2320
2524
|
return released;
|
|
2321
2525
|
}
|
|
2322
2526
|
|
|
2527
|
+
/** Stop one issued grant from being used. Idempotent: revoking twice is the same authorisation state. */
|
|
2528
|
+
revokeGrant({ grantId, reason = null, actor = { kind: "operator", id: "operator" } }) {
|
|
2529
|
+
if (typeof grantId !== "string" || grantId === "") throw new ProtocolError("BAD_REQUEST", "grant_id is required");
|
|
2530
|
+
const existing = this.revokedGrants.get(grantId);
|
|
2531
|
+
if (existing !== undefined) return { ok: true, already_revoked: true, revocation: { ...existing } };
|
|
2532
|
+
const revocation = { grant_id: grantId, reason: typeof reason === "string" && reason !== "" ? reason : null, revoked_at: this.nowIso(), revoked_by: actor };
|
|
2533
|
+
this.revokedGrants.set(grantId, revocation);
|
|
2534
|
+
this._ledger({ type: "grant.revoked", grant_id: grantId, reason: revocation.reason, revoked_by: actor });
|
|
2535
|
+
return { ok: true, already_revoked: false, revocation: { ...revocation } };
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
/** Audit view: every task that carried a grant, plus every revocation. Operator credential only. */
|
|
2539
|
+
grantedTasks() {
|
|
2540
|
+
const tasks = [];
|
|
2541
|
+
for (const task of this.tasks.values()) {
|
|
2542
|
+
if (task.grant_summary === null || task.grant_summary === undefined) continue;
|
|
2543
|
+
tasks.push({ task_id: task.task_id, grant_id: task.grant_summary.grant_id, argv_hash: task.grant_summary.argv_hash, key_id: task.grant_summary.key_id, mode: task.mode, created_at: rfc3339(task.created_at_ms) });
|
|
2544
|
+
}
|
|
2545
|
+
return { tasks, revoked: [...this.revokedGrants.values()].map((entry) => ({ ...entry })) };
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
/** Register a content-addressed artifact against a project (idempotent by sha256). */
|
|
2549
|
+
registerArtifact({ projectId, sha256, bytes, kind = "artifact", sourceTaskId = null, actor = null }) {
|
|
2550
|
+
if (!/^[0-9a-f]{64}$/.test(String(sha256))) throw new ProtocolError("BAD_REQUEST", "artifact sha256 must be 64 hex", {});
|
|
2551
|
+
const wanted = this.ensureProject(projectId);
|
|
2552
|
+
const existing = this.artifacts.get(sha256);
|
|
2553
|
+
if (existing !== undefined && existing.project_id === wanted) return { ok: true, artifact: { ...existing }, already: true };
|
|
2554
|
+
const artifact = { project_id: wanted, sha256: String(sha256), bytes: Number.isInteger(bytes) && bytes >= 0 ? bytes : null, kind: String(kind), source_task_id: sourceTaskId, created_at: this.nowIso(), actor };
|
|
2555
|
+
this.artifacts.set(sha256, artifact);
|
|
2556
|
+
try { this.projects.appendEvent({ projectId: wanted, type: "artifact.registered", actor: actor ?? { kind: "operator", id: "relay" }, payload: { sha256: artifact.sha256, bytes: artifact.bytes, kind: artifact.kind, source_task_id: sourceTaskId } }); } catch { /* history */ }
|
|
2557
|
+
return { ok: true, artifact: { ...artifact }, already: false };
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
listArtifacts({ projectId }) {
|
|
2561
|
+
const wanted = this.ensureProject(projectId);
|
|
2562
|
+
return [...this.artifacts.values()].filter((artifact) => artifact.project_id === wanted).sort((a, b) => (a.created_at < b.created_at ? 1 : -1)).map((artifact) => ({ ...artifact }));
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
/** The project board: every task attributed to this project, with the scheduler's own status. */
|
|
2566
|
+
projectBoard({ projectId }) {
|
|
2567
|
+
const wanted = this.ensureProject(projectId);
|
|
2568
|
+
const tasks = [...this.tasks.values()].filter((task) => task.project_id === wanted).map((task) => {
|
|
2569
|
+
const summary = this.taskSummary(task);
|
|
2570
|
+
return { task_id: summary.task_id, status: summary.status, mode: summary.mode, created_at: summary.created_at, leases: summary.leases, lease_states: summary.lease_states, result_count: [...this.results.values()].filter((record) => record?.envelope?.task_id === task.task_id).length };
|
|
2571
|
+
});
|
|
2572
|
+
const counts = {};
|
|
2573
|
+
for (const task of tasks) counts[task.status] = (counts[task.status] ?? 0) + 1;
|
|
2574
|
+
return { project_id: wanted, tasks, counts, generated_at: this.nowIso() };
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
/** Append a decision to a project. Decisions are records; there is no edit or delete. */
|
|
2578
|
+
addDecision({ projectId, title, text = null, actor = null, refs = {}, kind = "decision" }) {
|
|
2579
|
+
const wanted = this.ensureProject(projectId);
|
|
2580
|
+
if (typeof title !== "string" || title.trim() === "") throw new ProtocolError("BAD_REQUEST", "decision title is required", {});
|
|
2581
|
+
const list = this.decisions.get(wanted) ?? [];
|
|
2582
|
+
const index = list.length + 1;
|
|
2583
|
+
const decision = {
|
|
2584
|
+
decision_id: wanted + "-d" + String(index).padStart(4, "0"),
|
|
2585
|
+
project_id: wanted, title: title.trim().slice(0, 200), text: typeof text === "string" ? text.slice(0, 4000) : null,
|
|
2586
|
+
kind: String(kind), refs: refs !== null && typeof refs === "object" ? { ...refs } : {},
|
|
2587
|
+
actor, created_at: this.nowIso(),
|
|
2588
|
+
};
|
|
2589
|
+
list.push(decision);
|
|
2590
|
+
this.decisions.set(wanted, list);
|
|
2591
|
+
try { this.projects.appendEvent({ projectId: wanted, type: "decision.recorded", actor: actor ?? { kind: "operator", id: "relay" }, payload: { decision_id: decision.decision_id, title: decision.title, kind: decision.kind } }); } catch { /* history */ }
|
|
2592
|
+
return { ok: true, decision: { ...decision } };
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
listDecisions({ projectId }) {
|
|
2596
|
+
const wanted = this.ensureProject(projectId);
|
|
2597
|
+
return (this.decisions.get(wanted) ?? []).map((decision) => ({ ...decision, refs: { ...decision.refs } }));
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
/** A small versioned document per project (64 KiB cap): shared context that is not a log. */
|
|
2601
|
+
setProjectMemory({ projectId, text, actor = null }) {
|
|
2602
|
+
const wanted = this.ensureProject(projectId);
|
|
2603
|
+
if (typeof text !== "string") throw new ProtocolError("BAD_REQUEST", "memory text must be a string", {});
|
|
2604
|
+
if (Buffer.byteLength(text, "utf8") > 64 * 1024) throw new ProtocolError("PROJECT_MEMORY_TOO_LARGE", "project memory is limited to 64 KiB", { bytes: Buffer.byteLength(text, "utf8") });
|
|
2605
|
+
const previous = this.projectMemory.get(wanted);
|
|
2606
|
+
const doc = { project_id: wanted, text, version: (previous?.version ?? 0) + 1, updated_at: this.nowIso(), actor };
|
|
2607
|
+
this.projectMemory.set(wanted, doc);
|
|
2608
|
+
try { this.projects.appendEvent({ projectId: wanted, type: "memory.updated", actor: actor ?? { kind: "operator", id: "relay" }, payload: { version: doc.version, bytes: Buffer.byteLength(text, "utf8") } }); } catch { /* history */ }
|
|
2609
|
+
return { ok: true, document: { ...doc } };
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
getProjectMemory({ projectId }) {
|
|
2613
|
+
const wanted = this.ensureProject(projectId);
|
|
2614
|
+
const doc = this.projectMemory.get(wanted);
|
|
2615
|
+
return doc === undefined ? { project_id: wanted, text: "", version: 0, updated_at: null, actor: null } : { ...doc };
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
/** Subscribe to every project event. Returns an unsubscribe function. */
|
|
2619
|
+
onProjectEvent(listener) {
|
|
2620
|
+
this.projectSubscribers.add(listener);
|
|
2621
|
+
return () => { this.projectSubscribers.delete(listener); };
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2323
2624
|
taskSummary(task) {
|
|
2324
2625
|
return {
|
|
2325
2626
|
task_id: task.task_id,
|
|
@@ -2337,8 +2638,14 @@ export class RabbitState {
|
|
|
2337
2638
|
* Before this, GET /v1/tasks carried no status at all, so a board classified a waiting task as "ready" (its dependencies
|
|
2338
2639
|
* map empty) and a released task as "waiting" forever. The board must read the scheduler's own words or it is decoration.
|
|
2339
2640
|
*/
|
|
2641
|
+
project_id: task.project_id ?? null,
|
|
2340
2642
|
reduce_of: task.reduce_of ?? null,
|
|
2341
2643
|
reduce_inputs: Array.isArray(task.reduce_inputs) ? task.reduce_inputs : [],
|
|
2644
|
+
maintenance_request_id: task.maintenance_summary?.request_id ?? null,
|
|
2645
|
+
workspace: task.workspace === null || task.workspace === undefined ? null : { ...task.workspace },
|
|
2646
|
+
grant_id: task.grant_summary?.grant_id ?? null,
|
|
2647
|
+
argv_hash: task.grant_summary?.argv_hash ?? null,
|
|
2648
|
+
grant_key_id: task.grant_summary?.key_id ?? null,
|
|
2342
2649
|
status: (() => {
|
|
2343
2650
|
if (task.cancelled === true) return 'cancelled';
|
|
2344
2651
|
const leases = [...task.leases.values()];
|