@twinsearth/w2m-dsh-plugin 1.3.11 → 1.4.1
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 +151 -0
- package/bin/w2m-grant.mjs +98 -0
- package/codex/mcp-servers.toml +1 -1
- package/deploy/maintenance/bootstrap.sh +2 -0
- package/docs/COLLAB/EXECUTION-AUTH-MODEL.md +112 -1
- 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/docs/acceptance/1.4.1/M1-LIVE-DIAG.json +17 -0
- package/docs/acceptance/1.4.1/TWIN-GRANT-LIVE.json +23 -0
- package/package.json +7 -4
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +77 -1
- package/src/agent/grant-gate.mjs +8 -1
- package/src/agent/worktree.mjs +9 -0
- package/src/plugin/tools.mjs +1 -1
- package/src/relay/project-notifications.mjs +2 -0
- package/src/relay/project-refs.mjs +110 -0
- package/src/relay/server.mjs +218 -0
- package/src/relay/state.mjs +194 -2
package/src/relay/state.mjs
CHANGED
|
@@ -30,7 +30,9 @@ import { createProjectLocks } from './project-locks.mjs';
|
|
|
30
30
|
import { createProjectReviews } from './project-reviews.mjs';
|
|
31
31
|
import { createProjectNotifications } from './project-notifications.mjs';
|
|
32
32
|
import { createBlobSessions } from './blob-sessions.mjs';
|
|
33
|
+
import { createProjectRefs } from './project-refs.mjs';
|
|
33
34
|
import { createMetrics } from './reliability-metrics.mjs';
|
|
35
|
+
import { argvHash as grantArgvHash } from '../maintenance/grant.mjs';
|
|
34
36
|
|
|
35
37
|
export const PROTOCOL_VERSION = 1;
|
|
36
38
|
|
|
@@ -43,6 +45,11 @@ export const PROTOCOL_VERSION = 1;
|
|
|
43
45
|
export const ERROR_STATUS = Object.freeze({
|
|
44
46
|
UNAUTHORIZED: 401,
|
|
45
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,
|
|
46
53
|
NOT_FOUND: 404,
|
|
47
54
|
PAIRING_INVALID: 400,
|
|
48
55
|
PAIRING_EXPIRED: 400,
|
|
@@ -519,10 +526,41 @@ export class RabbitState {
|
|
|
519
526
|
this.operatorKeys = new Map();
|
|
520
527
|
// v1.3.10-beta.13: shared project objects (M4). Tasks name a project; readers get snapshot + events.
|
|
521
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
|
+
});
|
|
522
544
|
// Every project-room event is counted once, here: the room's own events and the lock/review/notification events projected
|
|
523
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
|
+
});
|
|
524
555
|
const metricsEvent = (event) => {
|
|
525
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
|
+
}
|
|
526
564
|
};
|
|
527
565
|
this.projects = createProjectRoom({ now: () => this.nowMs(), onEvent: metricsEvent });
|
|
528
566
|
this.defaultProjectId = 'default';
|
|
@@ -538,6 +576,9 @@ export class RabbitState {
|
|
|
538
576
|
this.notifications = createProjectNotifications({ now: () => this.nowMs(), onEvent: roomEvent });
|
|
539
577
|
// v1.3.10-beta.20: a committed chunk session becomes a fleet blob with the same verified bytes. A store failure does not
|
|
540
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();
|
|
541
582
|
this.blobSessions = createBlobSessions({
|
|
542
583
|
now: () => this.nowMs(),
|
|
543
584
|
onCommit: (blob) => {
|
|
@@ -780,6 +821,14 @@ export class RabbitState {
|
|
|
780
821
|
depends_on: Array.isArray(t.depends_on) ? t.depends_on : [],
|
|
781
822
|
assignee: typeof t.assignee === 'string' && t.assignee !== '' ? t.assignee : null,
|
|
782
823
|
project_id: typeof t.project_id === 'string' ? t.project_id : null,
|
|
824
|
+
// v1.4.1: an authorization or workspace must survive a relay restart, or a re-offered task reaches the machine
|
|
825
|
+
// without the grant/key/workspace it was dispatched with and the machine's own gate refuses it for the wrong reason.
|
|
826
|
+
workspace: t.workspace ?? null,
|
|
827
|
+
grant: t.grant ?? null,
|
|
828
|
+
grant_summary: t.grant_summary ?? null,
|
|
829
|
+
maintenance: t.maintenance ?? null,
|
|
830
|
+
maintenance_summary: t.maintenance_summary ?? null,
|
|
831
|
+
operator_key: t.operator_key ?? null,
|
|
783
832
|
reduce_of: typeof t.reduce_of === 'string' ? t.reduce_of : null,
|
|
784
833
|
reduce_inputs: Array.isArray(t.reduce_inputs) ? t.reduce_inputs : [],
|
|
785
834
|
leases: new Map(),
|
|
@@ -1791,6 +1840,25 @@ export class RabbitState {
|
|
|
1791
1840
|
* 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
1841
|
* grant is refused at dispatch, but the agent is the verifier — the relay never decides authorisation on its own.
|
|
1793
1842
|
*/
|
|
1843
|
+
/**
|
|
1844
|
+
* v1.3.13-beta.1 (task model): a task may explain WHICH copy of the project it runs on. The relay validates the
|
|
1845
|
+
* descriptor and carries it, exactly as it carries argv: the machine that owns the copy decides whether it can honour
|
|
1846
|
+
* it (and the agent currently refuses a workspace task with WORKSPACE_UNSUPPORTED until the execution slice lands).
|
|
1847
|
+
*/
|
|
1848
|
+
const rawWorkspace = input.workspace ?? null;
|
|
1849
|
+
let normalizedWorkspace = null;
|
|
1850
|
+
if (rawWorkspace !== null) {
|
|
1851
|
+
if (typeof rawWorkspace !== 'object' || Array.isArray(rawWorkspace) || Buffer.byteLength(JSON.stringify(rawWorkspace), 'utf8') > 4 * 1024) {
|
|
1852
|
+
throw new ProtocolError('BAD_REQUEST', 'workspace must be an object of at most 4 KiB', {});
|
|
1853
|
+
}
|
|
1854
|
+
if (rawWorkspace.kind !== 'worktree') throw new ProtocolError('BAD_REQUEST', "workspace.kind must be 'worktree'", { kind: rawWorkspace.kind });
|
|
1855
|
+
if (typeof rawWorkspace.base_commit !== 'string' || !/^[0-9a-f]{7,64}$/.test(rawWorkspace.base_commit)) {
|
|
1856
|
+
throw new ProtocolError('BAD_REQUEST', 'workspace.base_commit must be a hex commit id', { base_commit: rawWorkspace.base_commit });
|
|
1857
|
+
}
|
|
1858
|
+
const produce = rawWorkspace.produce ?? 'patch';
|
|
1859
|
+
if (!['patch', 'commit', 'none'].includes(produce)) throw new ProtocolError('BAD_REQUEST', "workspace.produce must be patch, commit or none", { produce });
|
|
1860
|
+
normalizedWorkspace = { kind: 'worktree', base_commit: rawWorkspace.base_commit, produce };
|
|
1861
|
+
}
|
|
1794
1862
|
const rawGrant = input.grant ?? null;
|
|
1795
1863
|
let normalizedGrant = null;
|
|
1796
1864
|
if (rawGrant !== null) {
|
|
@@ -1800,6 +1868,23 @@ export class RabbitState {
|
|
|
1800
1868
|
if (typeof rawGrant.grant_id !== 'string' || rawGrant.grant_id === '' || !/^[0-9a-f]{64}$/.test(String(rawGrant.argv_hash ?? ''))) {
|
|
1801
1869
|
throw new ProtocolError('BAD_REQUEST', 'grant needs grant_id and a 64-hex argv_hash', {});
|
|
1802
1870
|
}
|
|
1871
|
+
// v1.3.12-beta.1: dispatch-side preflight of the execution-authorization model. The agent verifies the signature and
|
|
1872
|
+
// the use ledger; the relay refuses a grant that is plainly for another task or another argv, by name, before any
|
|
1873
|
+
// machine sees the task. This is a caller-bug check, not a second authorisation decision.
|
|
1874
|
+
if (typeof rawGrant.task_id === 'string' && rawGrant.task_id !== '' && rawGrant.task_id !== taskId) {
|
|
1875
|
+
throw new ProtocolError('GRANT_TASK_MISMATCH', 'the grant names another task_id', { grant_task_id: rawGrant.task_id, task_id: taskId });
|
|
1876
|
+
}
|
|
1877
|
+
if (grantArgvHash(commandArgv) !== String(rawGrant.argv_hash)) {
|
|
1878
|
+
throw new ProtocolError('GRANT_ARGV_MISMATCH', 'the grant argv_hash does not match command_argv', { argv_hash: String(rawGrant.argv_hash), computed: grantArgvHash(commandArgv) });
|
|
1879
|
+
}
|
|
1880
|
+
if (rawGrant.expires_at !== undefined) {
|
|
1881
|
+
const expiresAt = Date.parse(String(rawGrant.expires_at));
|
|
1882
|
+
if (Number.isNaN(expiresAt)) throw new ProtocolError('BAD_REQUEST', 'grant.expires_at is not a timestamp', { expires_at: rawGrant.expires_at });
|
|
1883
|
+
if (expiresAt <= this.nowMs()) throw new ProtocolError('GRANT_EXPIRED', 'the grant has already expired', { expires_at: rawGrant.expires_at });
|
|
1884
|
+
}
|
|
1885
|
+
if (this.revokedGrants.has(String(rawGrant.grant_id))) {
|
|
1886
|
+
throw new ProtocolError('GRANT_REVOKED', 'the grant was revoked by the operator', { grant_id: String(rawGrant.grant_id) });
|
|
1887
|
+
}
|
|
1803
1888
|
normalizedGrant = rawGrant;
|
|
1804
1889
|
}
|
|
1805
1890
|
const rawOperatorKey = input.operator_key ?? null;
|
|
@@ -1869,6 +1954,7 @@ export class RabbitState {
|
|
|
1869
1954
|
*/
|
|
1870
1955
|
project_id: this.ensureProject(input.project_id ?? null),
|
|
1871
1956
|
reduce_of: typeof input.reduce_of === 'string' && input.reduce_of !== '' ? input.reduce_of : null,
|
|
1957
|
+
workspace: normalizedWorkspace,
|
|
1872
1958
|
grant: normalizedGrant,
|
|
1873
1959
|
grant_summary: normalizedGrant === null ? null : { grant_id: normalizedGrant.grant_id, argv_hash: normalizedGrant.argv_hash, key_id: normalizedGrant.signature?.key_id ?? null },
|
|
1874
1960
|
/** v1.3.10-beta.2: a signed maintenance request travels with the task; the relay carries it, the root runner verifies it. */
|
|
@@ -2135,6 +2221,7 @@ export class RabbitState {
|
|
|
2135
2221
|
type: 'task.created',
|
|
2136
2222
|
task: {
|
|
2137
2223
|
task_id: task.task_id,
|
|
2224
|
+
workspace: task.workspace ?? null,
|
|
2138
2225
|
project_id: task.project_id,
|
|
2139
2226
|
mode: task.mode,
|
|
2140
2227
|
command_argv: task.command_argv,
|
|
@@ -2162,6 +2249,7 @@ export class RabbitState {
|
|
|
2162
2249
|
reduce_of: task.reduce_of ?? null,
|
|
2163
2250
|
maintenance_summary: task.maintenance_summary ?? null,
|
|
2164
2251
|
grant_summary: task.grant_summary ?? null,
|
|
2252
|
+
operator_key: task.operator_key ?? null,
|
|
2165
2253
|
reduce_inputs: Array.isArray(task.reduce_inputs) ? task.reduce_inputs : [],
|
|
2166
2254
|
origin_machine_id: task.origin_machine_id ?? null,
|
|
2167
2255
|
p2p: task.p2p ?? null,
|
|
@@ -2282,7 +2370,11 @@ export class RabbitState {
|
|
|
2282
2370
|
// v1.2.10: present only on a reduce task, so a normal offer stays byte-identical.
|
|
2283
2371
|
...(task.reduce_of !== null ? { reduce_of: task.reduce_of, reduce_inputs: task.reduce_inputs } : {}),
|
|
2284
2372
|
// 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
|
|
2373
|
+
...(task.maintenance !== null && task.maintenance !== undefined ? { maintenance: task.maintenance } : {}),
|
|
2374
|
+
// v1.4.1: a grant on a machine that has never pinned the operator key is useless unless the offer carries the key it should
|
|
2375
|
+
// trust. Maintenance requests always shipped it; grants must too, or the TOFU pin path is unreachable.
|
|
2376
|
+
...(task.operator_key !== null && task.operator_key !== undefined ? { operator_key: task.operator_key } : {}),
|
|
2377
|
+
...(task.workspace !== null && task.workspace !== undefined ? { workspace: task.workspace } : {}),
|
|
2286
2378
|
...(task.grant !== null && task.grant !== undefined ? { grant: task.grant } : {}),
|
|
2287
2379
|
cwd_rel: task.cwd_rel,
|
|
2288
2380
|
// Omitted when false, so a single-command offer for every existing caller stays byte-identical -- the
|
|
@@ -2444,6 +2536,103 @@ export class RabbitState {
|
|
|
2444
2536
|
return released;
|
|
2445
2537
|
}
|
|
2446
2538
|
|
|
2539
|
+
/** Stop one issued grant from being used. Idempotent: revoking twice is the same authorisation state. */
|
|
2540
|
+
revokeGrant({ grantId, reason = null, actor = { kind: "operator", id: "operator" } }) {
|
|
2541
|
+
if (typeof grantId !== "string" || grantId === "") throw new ProtocolError("BAD_REQUEST", "grant_id is required");
|
|
2542
|
+
const existing = this.revokedGrants.get(grantId);
|
|
2543
|
+
if (existing !== undefined) return { ok: true, already_revoked: true, revocation: { ...existing } };
|
|
2544
|
+
const revocation = { grant_id: grantId, reason: typeof reason === "string" && reason !== "" ? reason : null, revoked_at: this.nowIso(), revoked_by: actor };
|
|
2545
|
+
this.revokedGrants.set(grantId, revocation);
|
|
2546
|
+
this._ledger({ type: "grant.revoked", grant_id: grantId, reason: revocation.reason, revoked_by: actor });
|
|
2547
|
+
return { ok: true, already_revoked: false, revocation: { ...revocation } };
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
/** Audit view: every task that carried a grant, plus every revocation. Operator credential only. */
|
|
2551
|
+
grantedTasks() {
|
|
2552
|
+
const tasks = [];
|
|
2553
|
+
for (const task of this.tasks.values()) {
|
|
2554
|
+
if (task.grant_summary === null || task.grant_summary === undefined) continue;
|
|
2555
|
+
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) });
|
|
2556
|
+
}
|
|
2557
|
+
return { tasks, revoked: [...this.revokedGrants.values()].map((entry) => ({ ...entry })) };
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
/** Register a content-addressed artifact against a project (idempotent by sha256). */
|
|
2561
|
+
registerArtifact({ projectId, sha256, bytes, kind = "artifact", sourceTaskId = null, actor = null }) {
|
|
2562
|
+
if (!/^[0-9a-f]{64}$/.test(String(sha256))) throw new ProtocolError("BAD_REQUEST", "artifact sha256 must be 64 hex", {});
|
|
2563
|
+
const wanted = this.ensureProject(projectId);
|
|
2564
|
+
const existing = this.artifacts.get(sha256);
|
|
2565
|
+
if (existing !== undefined && existing.project_id === wanted) return { ok: true, artifact: { ...existing }, already: true };
|
|
2566
|
+
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 };
|
|
2567
|
+
this.artifacts.set(sha256, artifact);
|
|
2568
|
+
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 */ }
|
|
2569
|
+
return { ok: true, artifact: { ...artifact }, already: false };
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
listArtifacts({ projectId }) {
|
|
2573
|
+
const wanted = this.ensureProject(projectId);
|
|
2574
|
+
return [...this.artifacts.values()].filter((artifact) => artifact.project_id === wanted).sort((a, b) => (a.created_at < b.created_at ? 1 : -1)).map((artifact) => ({ ...artifact }));
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
/** The project board: every task attributed to this project, with the scheduler's own status. */
|
|
2578
|
+
projectBoard({ projectId }) {
|
|
2579
|
+
const wanted = this.ensureProject(projectId);
|
|
2580
|
+
const tasks = [...this.tasks.values()].filter((task) => task.project_id === wanted).map((task) => {
|
|
2581
|
+
const summary = this.taskSummary(task);
|
|
2582
|
+
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 };
|
|
2583
|
+
});
|
|
2584
|
+
const counts = {};
|
|
2585
|
+
for (const task of tasks) counts[task.status] = (counts[task.status] ?? 0) + 1;
|
|
2586
|
+
return { project_id: wanted, tasks, counts, generated_at: this.nowIso() };
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
/** Append a decision to a project. Decisions are records; there is no edit or delete. */
|
|
2590
|
+
addDecision({ projectId, title, text = null, actor = null, refs = {}, kind = "decision" }) {
|
|
2591
|
+
const wanted = this.ensureProject(projectId);
|
|
2592
|
+
if (typeof title !== "string" || title.trim() === "") throw new ProtocolError("BAD_REQUEST", "decision title is required", {});
|
|
2593
|
+
const list = this.decisions.get(wanted) ?? [];
|
|
2594
|
+
const index = list.length + 1;
|
|
2595
|
+
const decision = {
|
|
2596
|
+
decision_id: wanted + "-d" + String(index).padStart(4, "0"),
|
|
2597
|
+
project_id: wanted, title: title.trim().slice(0, 200), text: typeof text === "string" ? text.slice(0, 4000) : null,
|
|
2598
|
+
kind: String(kind), refs: refs !== null && typeof refs === "object" ? { ...refs } : {},
|
|
2599
|
+
actor, created_at: this.nowIso(),
|
|
2600
|
+
};
|
|
2601
|
+
list.push(decision);
|
|
2602
|
+
this.decisions.set(wanted, list);
|
|
2603
|
+
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 */ }
|
|
2604
|
+
return { ok: true, decision: { ...decision } };
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
listDecisions({ projectId }) {
|
|
2608
|
+
const wanted = this.ensureProject(projectId);
|
|
2609
|
+
return (this.decisions.get(wanted) ?? []).map((decision) => ({ ...decision, refs: { ...decision.refs } }));
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
/** A small versioned document per project (64 KiB cap): shared context that is not a log. */
|
|
2613
|
+
setProjectMemory({ projectId, text, actor = null }) {
|
|
2614
|
+
const wanted = this.ensureProject(projectId);
|
|
2615
|
+
if (typeof text !== "string") throw new ProtocolError("BAD_REQUEST", "memory text must be a string", {});
|
|
2616
|
+
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") });
|
|
2617
|
+
const previous = this.projectMemory.get(wanted);
|
|
2618
|
+
const doc = { project_id: wanted, text, version: (previous?.version ?? 0) + 1, updated_at: this.nowIso(), actor };
|
|
2619
|
+
this.projectMemory.set(wanted, doc);
|
|
2620
|
+
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 */ }
|
|
2621
|
+
return { ok: true, document: { ...doc } };
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
getProjectMemory({ projectId }) {
|
|
2625
|
+
const wanted = this.ensureProject(projectId);
|
|
2626
|
+
const doc = this.projectMemory.get(wanted);
|
|
2627
|
+
return doc === undefined ? { project_id: wanted, text: "", version: 0, updated_at: null, actor: null } : { ...doc };
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
/** Subscribe to every project event. Returns an unsubscribe function. */
|
|
2631
|
+
onProjectEvent(listener) {
|
|
2632
|
+
this.projectSubscribers.add(listener);
|
|
2633
|
+
return () => { this.projectSubscribers.delete(listener); };
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2447
2636
|
taskSummary(task) {
|
|
2448
2637
|
return {
|
|
2449
2638
|
task_id: task.task_id,
|
|
@@ -2465,7 +2654,10 @@ export class RabbitState {
|
|
|
2465
2654
|
reduce_of: task.reduce_of ?? null,
|
|
2466
2655
|
reduce_inputs: Array.isArray(task.reduce_inputs) ? task.reduce_inputs : [],
|
|
2467
2656
|
maintenance_request_id: task.maintenance_summary?.request_id ?? null,
|
|
2468
|
-
|
|
2657
|
+
workspace: task.workspace === null || task.workspace === undefined ? null : { ...task.workspace },
|
|
2658
|
+
grant_id: task.grant_summary?.grant_id ?? null,
|
|
2659
|
+
argv_hash: task.grant_summary?.argv_hash ?? null,
|
|
2660
|
+
grant_key_id: task.grant_summary?.key_id ?? null,
|
|
2469
2661
|
status: (() => {
|
|
2470
2662
|
if (task.cancelled === true) return 'cancelled';
|
|
2471
2663
|
const leases = [...task.leases.values()];
|