@twinsearth/w2m-dsh-plugin 1.3.11 → 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.
@@ -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) => {
@@ -1791,6 +1832,25 @@ export class RabbitState {
1791
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
1792
1833
  * grant is refused at dispatch, but the agent is the verifier — the relay never decides authorisation on its own.
1793
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
+ }
1794
1854
  const rawGrant = input.grant ?? null;
1795
1855
  let normalizedGrant = null;
1796
1856
  if (rawGrant !== null) {
@@ -1800,6 +1860,23 @@ export class RabbitState {
1800
1860
  if (typeof rawGrant.grant_id !== 'string' || rawGrant.grant_id === '' || !/^[0-9a-f]{64}$/.test(String(rawGrant.argv_hash ?? ''))) {
1801
1861
  throw new ProtocolError('BAD_REQUEST', 'grant needs grant_id and a 64-hex argv_hash', {});
1802
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
+ }
1803
1880
  normalizedGrant = rawGrant;
1804
1881
  }
1805
1882
  const rawOperatorKey = input.operator_key ?? null;
@@ -1869,6 +1946,7 @@ export class RabbitState {
1869
1946
  */
1870
1947
  project_id: this.ensureProject(input.project_id ?? null),
1871
1948
  reduce_of: typeof input.reduce_of === 'string' && input.reduce_of !== '' ? input.reduce_of : null,
1949
+ workspace: normalizedWorkspace,
1872
1950
  grant: normalizedGrant,
1873
1951
  grant_summary: normalizedGrant === null ? null : { grant_id: normalizedGrant.grant_id, argv_hash: normalizedGrant.argv_hash, key_id: normalizedGrant.signature?.key_id ?? null },
1874
1952
  /** v1.3.10-beta.2: a signed maintenance request travels with the task; the relay carries it, the root runner verifies it. */
@@ -2135,6 +2213,7 @@ export class RabbitState {
2135
2213
  type: 'task.created',
2136
2214
  task: {
2137
2215
  task_id: task.task_id,
2216
+ workspace: task.workspace ?? null,
2138
2217
  project_id: task.project_id,
2139
2218
  mode: task.mode,
2140
2219
  command_argv: task.command_argv,
@@ -2283,6 +2362,7 @@ export class RabbitState {
2283
2362
  ...(task.reduce_of !== null ? { reduce_of: task.reduce_of, reduce_inputs: task.reduce_inputs } : {}),
2284
2363
  // v1.3.10-beta.2: the signed request and its operator key travel together; the agent pins the key on first sight.
2285
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 } : {}),
2286
2366
  ...(task.grant !== null && task.grant !== undefined ? { grant: task.grant } : {}),
2287
2367
  cwd_rel: task.cwd_rel,
2288
2368
  // Omitted when false, so a single-command offer for every existing caller stays byte-identical -- the
@@ -2444,6 +2524,103 @@ export class RabbitState {
2444
2524
  return released;
2445
2525
  }
2446
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
+
2447
2624
  taskSummary(task) {
2448
2625
  return {
2449
2626
  task_id: task.task_id,
@@ -2465,7 +2642,10 @@ export class RabbitState {
2465
2642
  reduce_of: task.reduce_of ?? null,
2466
2643
  reduce_inputs: Array.isArray(task.reduce_inputs) ? task.reduce_inputs : [],
2467
2644
  maintenance_request_id: task.maintenance_summary?.request_id ?? null,
2468
- grant_id: task.grant_summary?.grant_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,
2469
2649
  status: (() => {
2470
2650
  if (task.cancelled === true) return 'cancelled';
2471
2651
  const leases = [...task.leases.values()];