@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.
@@ -25,6 +25,8 @@ import { Readable } from 'node:stream';
25
25
  import { access, constants as fsConstants } from 'node:fs/promises';
26
26
  import { existsSync, mkdirSync, mkdtempSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
27
27
  import { dirname, join } from 'node:path';
28
+ import { commitTaskChanges, createWorktree, listChangedFiles, producePatch, removeWorktree } from './worktree.mjs';
29
+ import { describePatchArtifact } from './patch-artifact.mjs';
28
30
  import { ferryMaintenance, maintenancePaths } from './maintenance-ferry.mjs';
29
31
  import { authorizeWithGrant } from './grant-gate.mjs';
30
32
  import { tmpdir } from 'node:os';
@@ -2583,6 +2585,8 @@ export function createAgent(options) {
2583
2585
  comparePolicy,
2584
2586
  pipeline,
2585
2587
  protocolViolations,
2588
+ authorization = null,
2589
+ workspace = null,
2586
2590
  } = context;
2587
2591
 
2588
2592
  const stdout = execution?.stdout ?? Buffer.alloc(0);
@@ -2605,6 +2609,11 @@ export function createAgent(options) {
2605
2609
 
2606
2610
  const envelope = {
2607
2611
  envelope_version: ENVELOPE_VERSION,
2612
+ // v1.3.12-beta.3: how this execution was authorised -- 'grant' (operator, one execution), 'allow-list' (standing machine
2613
+ // decision) or 'maintenance' (signed maintenance request). A reader can answer "who allowed this" without the ledger.
2614
+ authorization: authorization ?? null,
2615
+ // v1.3.13-beta.2: the copy this task ran on, and the patch it produced (evidence, not a promise).
2616
+ workspace: workspace ?? null,
2608
2617
  task_id: offer.task_id,
2609
2618
  attempt: Number(offer.attempt) || 1,
2610
2619
  dedupe_key: offer.dedupe_key ?? '',
@@ -2927,6 +2936,9 @@ export function createAgent(options) {
2927
2936
  /** @type {string[]} */
2928
2937
  const warnings = [];
2929
2938
 
2939
+ // Hoisted so the catch below can clean up a worktree even when the failure happened after creation.
2940
+ let workspaceState = null;
2941
+
2930
2942
  try {
2931
2943
  // ---- 3. capability gate (§6.1) -------------------------------------
2932
2944
  const writable = offer.write === true ? await isWritableProject(project) : true;
@@ -2976,6 +2988,10 @@ export function createAgent(options) {
2976
2988
  if (gate.ok === true) { allowed = { allowed: true, ok: true, grant_id: gate.grantId }; grantAuthorized = true; }
2977
2989
  else allowed = { ok: false, reason_code: gate.code ?? 'GRANT_REJECTED', detail: gate.message };
2978
2990
  }
2991
+ // One decision, two audiences: the gate decides whether to run, and the envelope records which decision it was.
2992
+ const authorization = grantAuthorized
2993
+ ? { provenance: 'grant', capability_id: allowed.grant_id ?? null, grant_id: allowed.grant_id ?? null }
2994
+ : { provenance: 'allow-list' };
2979
2995
  if (!allowed.allowed) {
2980
2996
  log('warn', `refused ${offer.task_id}: ${allowed.reason_code ?? REFUSAL.COMMAND_NOT_ALLOWED}`, { argv: commandArgv });
2981
2997
  await finish({
@@ -3087,6 +3103,7 @@ export function createAgent(options) {
3087
3103
  anchorsBefore,
3088
3104
  anchorsAfter: null,
3089
3105
  execution,
3106
+ authorization: { provenance: 'maintenance', capability_id: typeof offer.maintenance?.request_id === 'string' ? offer.maintenance.request_id : null },
3090
3107
  status: ferried.ok ? 'ok' : 'failed',
3091
3108
  refusalReason: ferried.ok ? null : 'MAINTENANCE_FAILED',
3092
3109
  warnings,
@@ -3141,11 +3158,34 @@ export function createAgent(options) {
3141
3158
  }
3142
3159
  }
3143
3160
 
3161
+ /**
3162
+ * v1.3.13-beta.2 (task model): run a workspace task in its own git worktree at the caller base_commit, then turn the
3163
+ * result into a content-addressed patch. Created only after the allow-list/grant gate, so a refused task never leaves a
3164
+ * tree behind; cleaned before the envelope is sent, so the reported evidence describes a finished state.
3165
+ */
3166
+ if (offer.workspace !== null && offer.workspace !== undefined) {
3167
+ if (offer.workspace.kind !== 'worktree') {
3168
+ await finish({ offer, cwdRel, commandArgv, anchorsBefore, anchorsAfter: null, execution: null, status: 'refused', refusalReason: 'WORKSPACE_UNSUPPORTED', warnings: ['WORKSPACE_UNSUPPORTED: only worktree workspaces exist'], startedAt: new Date().toISOString(), endedAt: new Date().toISOString() });
3169
+ return;
3170
+ }
3171
+ const workspaceDirName = String(offer.task_id).replace(/[^A-Za-z0-9._-]/g, '_');
3172
+ const worktreeDir = join(project, '.w2m-worktrees', workspaceDirName);
3173
+ const created = await createWorktree({ repoDir: project, baseCommit: offer.workspace.base_commit, worktreeDir });
3174
+ if (created.ok !== true) {
3175
+ log('warn', `refused ${offer.task_id}: WORKSPACE_CREATE_FAILED`, { code: created.code });
3176
+ await finish({ offer, cwdRel, commandArgv, anchorsBefore, anchorsAfter: null, execution: null, status: 'refused', refusalReason: 'WORKSPACE_CREATE_FAILED', warnings: ['WORKSPACE_CREATE_FAILED: ' + String(created.code ?? 'unknown')], startedAt: new Date().toISOString(), endedAt: new Date().toISOString(), protocolViolations: ['workspace could not be created'] });
3177
+ return;
3178
+ }
3179
+ workspaceState = { worktreeDir, baseCommit: offer.workspace.base_commit, produce: offer.workspace.produce ?? 'patch', relCwd: '.w2m-worktrees/' + workspaceDirName };
3180
+ log('info', `${offer.task_id} workspace ${workspaceState.baseCommit.slice(0, 12)} at ${workspaceState.relCwd}`);
3181
+ }
3182
+
3144
3183
  for (const stage of plannedStages) {
3145
3184
  // v1.3.10-beta.6: a grant that authorised step 5 covers the whole task, but only for the argv it signed.
3146
3185
  const stageAllowed = grantAuthorized ? { allowed: true, ok: true } : matchAllowedCommand(stage.command_argv, allowedCommands);
3147
3186
  if (stageAllowed.allowed) continue;
3148
3187
  const code = stageAllowed.reason_code ?? REFUSAL.COMMAND_NOT_ALLOWED;
3188
+ if (workspaceState !== null) { try { await removeWorktree({ repoDir: project, worktreeDir: workspaceState.worktreeDir }); } catch { /* best effort */ } }
3149
3189
  log('warn', `refused ${offer.task_id}: ${code} at stage ${stage.index}`, {
3150
3190
  argv: stage.command_argv,
3151
3191
  });
@@ -3186,7 +3226,13 @@ export function createAgent(options) {
3186
3226
  for (const stage of plannedStages) {
3187
3227
  heartbeat.setPhase('running', 50);
3188
3228
 
3189
- const stageCwdOutcome = resolveProjectCwd(project, stage.cwd_rel, { allowOutside: offer.cwd_outside_project === true });
3229
+ // A workspace task runs in its own copy: an explicit stage cwd is relative to that copy, and the default is its root.
3230
+ const workspaceRel = workspaceState === null
3231
+ ? null
3232
+ : (stage.cwd_rel === undefined || stage.cwd_rel === cwdRel || stage.cwd_rel === '.')
3233
+ ? workspaceState.relCwd
3234
+ : workspaceState.relCwd + '/' + normalizeRelPath(stage.cwd_rel);
3235
+ const stageCwdOutcome = resolveProjectCwd(project, workspaceRel ?? stage.cwd_rel, { allowOutside: offer.cwd_outside_project === true });
3190
3236
  if (!stageCwdOutcome.ok) {
3191
3237
  // A stage whose cwd is outside the project is a refusal of the whole chain: continuing
3192
3238
  // would run later stages against a working directory the caller never asked for.
@@ -3363,6 +3409,33 @@ export function createAgent(options) {
3363
3409
  if (execution?.timed_out) log('warn', `${offer.task_id} timed out after ${offer.timeout_ms}ms`);
3364
3410
  if (execution?.cancelled) log('warn', `${offer.task_id} was cancelled`);
3365
3411
 
3412
+ let workspaceEvidence = null;
3413
+ if (workspaceState !== null) {
3414
+ const commit = await commitTaskChanges({ worktreeDir: workspaceState.worktreeDir, message: 'task ' + offer.task_id });
3415
+ const patch = await producePatch({ worktreeDir: workspaceState.worktreeDir, baseCommit: workspaceState.baseCommit });
3416
+ const changed = await listChangedFiles({ worktreeDir: workspaceState.worktreeDir, baseCommit: workspaceState.baseCommit });
3417
+ const artifact = patch.ok === true ? describePatchArtifact({ patch: patch.patch, baseCommit: workspaceState.baseCommit, headCommit: commit.ok === true ? (commit.headCommit ?? null) : null, filesChanged: changed.ok === true ? changed.files : [] }) : null;
3418
+ let patchSha = null;
3419
+ if (patch.ok === true && typeof patch.patch === 'string' && patch.patch.trim() !== '') {
3420
+ const sha = sha256Hex(patch.patch);
3421
+ const uploaded = await putBlobBytes(sha, patch.patch);
3422
+ if (uploaded.ok === true) patchSha = sha;
3423
+ else warnings.push('PATCH_UPLOAD_FAILED');
3424
+ }
3425
+ const removed = await removeWorktree({ repoDir: project, worktreeDir: workspaceState.worktreeDir });
3426
+ if (removed.ok !== true) warnings.push('WORKTREE_CLEANUP_FAILED');
3427
+ workspaceEvidence = {
3428
+ kind: 'worktree',
3429
+ base_commit: workspaceState.baseCommit,
3430
+ produce: workspaceState.produce,
3431
+ head_commit: commit.ok === true ? (commit.headCommit ?? null) : null,
3432
+ changed_files: artifact !== null && artifact.ok === true ? artifact.filesChanged : [],
3433
+ patch_sha256: patchSha,
3434
+ patch_bytes: typeof patch.patch === 'string' ? Buffer.byteLength(patch.patch, 'utf8') : 0,
3435
+ cleaned: removed.ok === true,
3436
+ };
3437
+ }
3438
+
3366
3439
  const anchorsAfter = await safeAnchors(warnings);
3367
3440
  const status = execution ? classifyExit(execution) : (protocolViolations.length > 0 ? 'refused' : 'unverifiable');
3368
3441
 
@@ -3373,6 +3446,8 @@ export function createAgent(options) {
3373
3446
  anchorsBefore,
3374
3447
  anchorsAfter,
3375
3448
  execution,
3449
+ authorization,
3450
+ workspace: workspaceEvidence,
3376
3451
  blobSha256: lastBlobSha256,
3377
3452
  status,
3378
3453
  refusalReason: null,
@@ -3395,6 +3470,7 @@ export function createAgent(options) {
3395
3470
  // Never leave a claimed task unspooled: emit a crashed envelope so the
3396
3471
  // relay can account for it instead of waiting for a lease to expire.
3397
3472
  log('error', `error while handling ${offer.task_id}`, { error: error?.message ?? String(error) });
3473
+ if (workspaceState !== null) { try { await removeWorktree({ repoDir: project, worktreeDir: workspaceState.worktreeDir }); } catch { /* best effort */ } }
3398
3474
  try {
3399
3475
  const envelope = makeEnvelope({
3400
3476
  offer,
@@ -58,7 +58,14 @@ export async function authorizeWithGrant(options) {
58
58
 
59
59
  used.grants[grant.grant_id] = (used.grants[grant.grant_id] ?? 0) + 1;
60
60
  used.nonces = [...used.nonces, grant.nonce].slice(-500);
61
- writeUsed(usedFile, used);
61
+ // Fail closed: the use ledger is what makes a single-use grant single-use, so an unwritable ledger must refuse the task
62
+ // rather than run it unrecorded (and rather than crash the whole handler). Seen on a real host where the maintenance state
63
+ // directory was root-owned but the agent ran unprivileged.
64
+ try {
65
+ writeUsed(usedFile, used);
66
+ } catch (error) {
67
+ return { ok: false, code: 'GRANT_LEDGER_UNAVAILABLE', message: 'the grant use ledger cannot be written: ' + String(error?.message ?? error) };
68
+ }
62
69
  return { ok: true, grantId: grant.grant_id, used: used.grants[grant.grant_id] };
63
70
  }
64
71
 
@@ -98,6 +98,15 @@ export async function producePatch({ worktreeDir, baseCommit, git = defaultGit()
98
98
  }
99
99
 
100
100
  /** Remove a worktree. Failure is reported, never hidden: a leaked worktree blocks the next task on that path. */
101
+ export async function listChangedFiles({ worktreeDir, baseCommit, git = defaultGit() }) {
102
+ try {
103
+ const result = await git(['diff', '--name-only', baseCommit + '..HEAD'], worktreeDir);
104
+ return { ok: true, files: String(result.stdout).split(String.fromCharCode(10)).map((line) => line.trim()).filter((line) => line !== '').sort() };
105
+ } catch (error) {
106
+ return { ok: false, code: 'DIFF_FAILED', message: error?.message ?? 'git diff failed' };
107
+ }
108
+ }
109
+
101
110
  export async function removeWorktree({ repoDir, worktreeDir, force = true, git = defaultGit() }) {
102
111
  try {
103
112
  const args = ['worktree', 'remove', ...(force ? ['--force'] : []), worktreeDir];
@@ -274,7 +274,7 @@ const MAX_STAGES = 16;
274
274
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
275
275
  * overrides it at runtime for tests and packagers.
276
276
  */
277
- const PLUGIN_VERSION = '1.3.11';
277
+ const PLUGIN_VERSION = '1.4.0';
278
278
 
279
279
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
280
280
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -14,6 +14,8 @@
14
14
  export const NOTIFICATION_KINDS = Object.freeze([
15
15
  'task.assigned', 'review.requested', 'review.decided', 'lock.conflict', 'merge.conflict',
16
16
  'merge.merged', 'ref.updated', 'mention', 'maintenance.result', 'digest',
17
+ // v1.3.14-beta.3: shared-object events that a client must learn about.
18
+ 'decision.recorded', 'artifact.registered', 'memory.updated',
17
19
  ]);
18
20
  export const DEFAULT_NOTIFICATION_LIMITS = Object.freeze({
19
21
  dedupeWindowMs: 60 * 1000,
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Project refs and merge bookkeeping (v1.3.13-beta.3 slice).
3
+ *
4
+ * A workspace task produces a patch against a base; the task model needs one place that says where each branch actually is and
5
+ * what happened to each patch. This module records monotonic branch heads, a merge queue keyed by patch sha256, and first-class
6
+ * outcomes (merged / already_merged / base mismatch / conflict). git application belongs to the machine (merge-queue.mjs owns
7
+ * the repository work); the relay records the decision and refuses a claim it has already answered.
8
+ */
9
+ const SHA = /^[0-9a-f]{7,64}$/;
10
+ const PATCH = /^[0-9a-f]{64}$/;
11
+
12
+ export class RefError extends Error {
13
+ constructor(code, message, detail = null) {
14
+ super(message);
15
+ this.name = 'RefError';
16
+ this.code = code;
17
+ this.detail = detail;
18
+ }
19
+ }
20
+
21
+ const keyOf = (projectId, branch) => projectId + '|' + branch;
22
+
23
+ export function createProjectRefs({ now = () => Date.now(), onEvent = null } = {}) {
24
+ const heads = new Map();
25
+ const items = new Map();
26
+ let counter = 0;
27
+ let seq = 0;
28
+ const emit = (type, payload) => {
29
+ seq += 1;
30
+ const event = { seq, type, at: new Date(now()).toISOString(), payload };
31
+ if (typeof onEvent === 'function') onEvent(event);
32
+ return event;
33
+ };
34
+ return {
35
+ head({ projectId, branch }) {
36
+ return heads.get(keyOf(projectId, branch)) ?? null;
37
+ },
38
+ /**
39
+ * Advance a branch. The caller names the head it built on; if that is not the current head the advance is refused
40
+ * (REF_BASE_MISMATCH), because silently moving someone else's base is how intent gets lost.
41
+ */
42
+ advance({ projectId, branch, from = null, to, actor = null, reason = null }) {
43
+ if (typeof projectId !== 'string' || projectId === '') throw new RefError('INVALID_PROJECT', 'project_id is required');
44
+ if (typeof branch !== 'string' || branch === '') throw new RefError('INVALID_BRANCH', 'branch is required');
45
+ if (!SHA.test(String(to))) throw new RefError('INVALID_HEAD', 'to must be a hex commit id', { to });
46
+ const current = heads.get(keyOf(projectId, branch));
47
+ const currentHead = current?.head_commit ?? null;
48
+ if (from !== currentHead) {
49
+ return { ok: false, code: currentHead === null ? 'REF_UNINITIALIZED' : 'REF_BASE_MISMATCH', expected: currentHead, from };
50
+ }
51
+ if (from !== null && String(to) === from) return { ok: false, code: 'REF_NO_CHANGE', head: from };
52
+ const head = { project_id: projectId, branch, head_commit: String(to), version: (current?.version ?? 0) + 1, updated_at: new Date(now()).toISOString() };
53
+ heads.set(keyOf(projectId, branch), head);
54
+ emit('ref.advanced', { ...head, from, actor, reason });
55
+ return { ok: true, head: { ...head }, from };
56
+ },
57
+ listRefs({ projectId }) {
58
+ return [...heads.values()].filter((head) => head.project_id === projectId).map((head) => ({ ...head }));
59
+ },
60
+ /** Queue a patch. A patch already merged on that branch is answered, not queued twice. */
61
+ enqueue({ projectId, branch, patchSha256, baseCommit, sourceTaskId = null }) {
62
+ if (!PATCH.test(String(patchSha256))) return { ok: false, code: 'INVALID_PATCH_SHA' };
63
+ if (!SHA.test(String(baseCommit))) return { ok: false, code: 'INVALID_BASE_COMMIT' };
64
+ for (const item of items.values()) {
65
+ if (item.project_id === projectId && item.branch === branch && item.patch_sha256 === patchSha256 && item.state === 'merged') {
66
+ return { ok: true, already_merged: true, item: { ...item } };
67
+ }
68
+ }
69
+ counter += 1;
70
+ const item = {
71
+ queue_id: 'mq-' + String(counter).padStart(6, '0'),
72
+ project_id: projectId,
73
+ branch,
74
+ patch_sha256: String(patchSha256),
75
+ base_commit: String(baseCommit),
76
+ source_task_id: sourceTaskId,
77
+ state: 'queued',
78
+ created_at: new Date(now()).toISOString(),
79
+ };
80
+ items.set(item.queue_id, item);
81
+ emit('merge.queued', { ...item });
82
+ return { ok: true, already_merged: false, item: { ...item } };
83
+ },
84
+ /** Record a terminal outcome. Only 'merged' advances the branch, and only from the recorded base. */
85
+ complete({ queueId, outcome, headCommit = null, conflictSha256 = null }) {
86
+ const item = items.get(queueId);
87
+ if (item === undefined) return { ok: false, code: 'MERGE_ITEM_NOT_FOUND' };
88
+ if (item.state !== 'queued') return { ok: false, code: 'MERGE_ITEM_ALREADY_SETTLED', state: item.state };
89
+ if (!['merged', 'conflict', 'failed'].includes(outcome)) return { ok: false, code: 'INVALID_OUTCOME', outcome };
90
+ if (outcome === 'merged') {
91
+ const moved = this.advance({ projectId: item.project_id, branch: item.branch, from: item.base_commit, to: headCommit });
92
+ if (moved.ok !== true) return { ok: false, code: 'REF_ADVANCE_REFUSED', ref: moved };
93
+ }
94
+ item.state = outcome;
95
+ item.head_commit = outcome === 'merged' ? String(headCommit) : null;
96
+ item.conflict_sha256 = outcome === 'conflict' && PATCH.test(String(conflictSha256)) ? String(conflictSha256) : null;
97
+ item.settled_at = new Date(now()).toISOString();
98
+ emit('merge.' + outcome, { ...item });
99
+ return { ok: true, item: { ...item } };
100
+ },
101
+ listQueue({ projectId, branch = null }) {
102
+ return [...items.values()]
103
+ .filter((item) => item.project_id === projectId && (branch === null || item.branch === branch))
104
+ .map((item) => ({ ...item }));
105
+ },
106
+ size() {
107
+ return { heads: heads.size, items: items.size, events: seq };
108
+ },
109
+ };
110
+ }
@@ -846,6 +846,223 @@ export class RelayServer {
846
846
  }
847
847
 
848
848
 
849
+ /**
850
+ * v1.3.12-beta.2: grant administration (execution-authorization model).
851
+ *
852
+ * Revoking is an operator act: it stops one authorisation from being dispatched. The task list is the audit view --
853
+ * every task that carried a grant, with the argv hash and key id it was authorised under -- and deliberately returns
854
+ * only operator-readable data, never result bodies.
855
+ */
856
+ if (path === '/v1/grants/revoke' || path === '/v1/grants') {
857
+ const auth = this.authorizeTaskDispatch(req);
858
+ if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'only the operator may administer grants');
859
+ if (method === 'POST' && path === '/v1/grants/revoke') {
860
+ const body = takeJson();
861
+ const revoked = this.state.revokeGrant({ grantId: body?.grant_id, reason: body?.reason ?? null });
862
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...revoked });
863
+ }
864
+ if (method === 'GET' && path === '/v1/grants') {
865
+ const audit = this.state.grantedTasks();
866
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...audit });
867
+ }
868
+ throw new ProtocolError('BAD_REQUEST', 'grants accept GET (audit) or POST /revoke', { method, path });
869
+ }
870
+
871
+ /**
872
+ * v1.3.13-beta.4: project refs and the merge queue (task model: branch, merge, conflict).
873
+ *
874
+ * Reads take the device token of a paired machine; advancing a ref and settling a queue item are operator acts, because
875
+ * they state where the branch is and what a patch did. A merge that would move the head from a base that is no longer
876
+ * current is refused and the item stays queued -- the model never silently rewrites history.
877
+ */
878
+ const refsMatch = /^\/v1\/projects\/([^/]+)\/refs(?:\/advance)?$/.exec(path);
879
+ if (refsMatch !== null) {
880
+ const projectId = decodeURIComponent(refsMatch[1]);
881
+ const wantsAdvance = path.endsWith('/advance');
882
+ if (!wantsAdvance && method === 'GET') {
883
+ const token = bearerToken(req);
884
+ if (token === null || this.state.authenticate(token) === null) throw new ProtocolError('UNAUTHORIZED', 'reading refs needs a paired device token');
885
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, refs: this.state.refs.listRefs({ projectId }) });
886
+ }
887
+ if (wantsAdvance && method === 'POST') {
888
+ const auth = this.authorizeTaskDispatch(req);
889
+ if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'advancing a ref is an operator act');
890
+ const body = takeJson();
891
+ const moved = this.state.refs.advance({ projectId, branch: body?.branch, from: body?.from ?? null, to: body?.to, reason: body?.reason ?? null });
892
+ return sendJson(res, moved.ok === true ? 200 : 409, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...moved });
893
+ }
894
+ throw new ProtocolError('BAD_REQUEST', 'refs accept GET or POST /advance', { method, path });
895
+ }
896
+ const mergeMatch = /^\/v1\/projects\/([^/]+)\/merge-queue(?:\/([^/]+))?(?:\/(complete))?$/.exec(path);
897
+ if (mergeMatch !== null) {
898
+ const projectId = decodeURIComponent(mergeMatch[1]);
899
+ const queueId = mergeMatch[2] === undefined ? null : decodeURIComponent(mergeMatch[2]);
900
+ const action = mergeMatch[3] ?? null;
901
+ if (method === 'GET' && queueId === null) {
902
+ const token = bearerToken(req);
903
+ if (token === null || this.state.authenticate(token) === null) throw new ProtocolError('UNAUTHORIZED', 'reading the merge queue needs a paired device token');
904
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, items: this.state.refs.listQueue({ projectId }) });
905
+ }
906
+ const auth = this.authorizeTaskDispatch(req);
907
+ if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'merge queue mutations are operator acts');
908
+ if (method === 'POST' && queueId === null) {
909
+ const body = takeJson();
910
+ const queued = this.state.refs.enqueue({ projectId, branch: body?.branch, patchSha256: body?.patch_sha256, baseCommit: body?.base_commit, sourceTaskId: body?.source_task_id ?? null });
911
+ return sendJson(res, queued.ok === true ? 200 : 400, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...queued });
912
+ }
913
+ if (method === 'POST' && queueId !== null && action === 'complete') {
914
+ const body = takeJson();
915
+ const settled = this.state.refs.complete({ queueId, outcome: body?.outcome, headCommit: body?.head_commit ?? null, conflictSha256: body?.conflict_sha256 ?? null });
916
+ return sendJson(res, settled.ok === true ? 200 : 409, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...settled });
917
+ }
918
+ throw new ProtocolError('BAD_REQUEST', 'merge queue accepts GET, POST enqueue or POST /:id/complete', { method, path });
919
+ }
920
+
921
+ /**
922
+ * v1.3.14-beta.1 (collaboration model): shared objects. Two clients in the same project read the SAME board and the SAME
923
+ * artifact index; before this they each filtered the global task list and disagreed as soon as one of them looked late.
924
+ */
925
+ const sharedMatch = /^\/v1\/projects\/([^/]+)\/(board|artifacts)$/.exec(path);
926
+ if (sharedMatch !== null) {
927
+ const projectId = decodeURIComponent(sharedMatch[1]);
928
+ const resource = sharedMatch[2];
929
+ const readDevice = () => {
930
+ const token = bearerToken(req);
931
+ const caller = token === null ? null : this.state.authenticate(token);
932
+ if (caller === null) throw new ProtocolError('UNAUTHORIZED', 'reading a project object needs a paired device token');
933
+ return caller;
934
+ };
935
+ if (method === 'GET') {
936
+ const caller = readDevice();
937
+ const snapshot = this.state.projectSnapshot(projectId);
938
+ const isDefault = snapshot.project.project_id === this.state.defaultProjectId;
939
+ if (!isDefault) {
940
+ const member = this.state.projects.memberOf({ projectId, member: { kind: 'machine', id: caller.machine_id } });
941
+ if (member === null) throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: projectId });
942
+ }
943
+ if (resource === 'board') return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...this.state.projectBoard({ projectId }) });
944
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, artifacts: this.state.listArtifacts({ projectId }) });
945
+ }
946
+ if (method === 'POST' && resource === 'artifacts') {
947
+ const auth = this.authorizeTaskDispatch(req);
948
+ if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'registering a project artifact is an operator act');
949
+ const body = takeJson();
950
+ const registered = this.state.registerArtifact({ projectId, sha256: body?.sha256, bytes: body?.bytes, kind: body?.kind ?? 'artifact', sourceTaskId: body?.source_task_id ?? null });
951
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...registered });
952
+ }
953
+ throw new ProtocolError('BAD_REQUEST', 'project objects accept GET, or POST artifacts', { method, path });
954
+ }
955
+
956
+ /**
957
+ * v1.3.14-beta.2 (collaboration model): decisions and project memory as shared objects. The board answers "what is
958
+ * happening", these answer "what did we agree" -- and both are one object per project, not a per-client copy.
959
+ */
960
+ const knowledgeMatch = /^\/v1\/projects\/([^/]+)\/(decisions|memory)$/.exec(path);
961
+ if (knowledgeMatch !== null) {
962
+ const projectId = decodeURIComponent(knowledgeMatch[1]);
963
+ const resource = knowledgeMatch[2];
964
+ const readDevice = () => {
965
+ const token = bearerToken(req);
966
+ const caller = token === null ? null : this.state.authenticate(token);
967
+ if (caller === null) throw new ProtocolError('UNAUTHORIZED', 'reading a project object needs a paired device token');
968
+ return caller;
969
+ };
970
+ const requireReadable = (caller) => {
971
+ const snapshot = this.state.projectSnapshot(projectId);
972
+ if (snapshot.project.project_id === this.state.defaultProjectId) return;
973
+ const member = this.state.projects.memberOf({ projectId, member: { kind: 'machine', id: caller.machine_id } });
974
+ if (member === null) throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: projectId });
975
+ };
976
+ if (method === 'GET') {
977
+ const caller = readDevice();
978
+ requireReadable(caller);
979
+ if (resource === 'decisions') return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, decisions: this.state.listDecisions({ projectId }) });
980
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, document: this.state.getProjectMemory({ projectId }) });
981
+ }
982
+ const auth = this.authorizeTaskDispatch(req);
983
+ if (auth.role !== 'operator') throw new ProtocolError('OPERATOR_REQUIRED', 'writing shared project objects is an operator act');
984
+ const body = takeJson();
985
+ if (method === 'POST' && resource === 'decisions') {
986
+ const added = this.state.addDecision({ projectId, title: body?.title, text: body?.text ?? null, actor: { kind: 'operator', id: 'operator' }, refs: body?.refs ?? {}, kind: body?.kind ?? 'decision' });
987
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...added });
988
+ }
989
+ if (method === 'PUT' && resource === 'memory') {
990
+ const updated = this.state.setProjectMemory({ projectId, text: body?.text, actor: { kind: 'operator', id: 'operator' } });
991
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...updated });
992
+ }
993
+ throw new ProtocolError('BAD_REQUEST', 'decisions accept GET/POST, memory accepts GET/PUT', { method, path });
994
+ }
995
+
996
+ /**
997
+ * v1.3.14-beta.3 (collaboration model): notifications for shared-object events. The rows are broadcast to project
998
+ * members, read with a per-reader watermark, and deduplicated inside the notifications module; a client that reconnects
999
+ * asks for everything after its watermark instead of re-reading the whole event stream.
1000
+ */
1001
+ const notifyMatch = /^\/v1\/projects\/([^/]+)\/notifications(?:\/(unread|read))?$/.exec(path);
1002
+ if (notifyMatch !== null) {
1003
+ const projectId = decodeURIComponent(notifyMatch[1]);
1004
+ const action = notifyMatch[2] ?? null;
1005
+ const token = bearerToken(req);
1006
+ const caller = token === null ? null : this.state.authenticate(token);
1007
+ if (caller === null) throw new ProtocolError('UNAUTHORIZED', 'notifications need a paired device token');
1008
+ const snapshot = this.state.projectSnapshot(projectId);
1009
+ if (snapshot.project.project_id !== this.state.defaultProjectId) {
1010
+ const member = this.state.projects.memberOf({ projectId, member: { kind: 'machine', id: caller.machine_id } });
1011
+ if (member === null) throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: projectId });
1012
+ }
1013
+ const reader = { kind: 'machine', id: caller.machine_id };
1014
+ if (method === 'GET' && action === null) {
1015
+ const url = new URL(req.url, 'http://relay.invalid');
1016
+ const sinceMs = Number(url.searchParams.get('since_ms') ?? 0);
1017
+ const limit = Number(url.searchParams.get('limit') ?? 200);
1018
+ const listed = this.state.notifications.list({ projectId, sinceMs: Number.isFinite(sinceMs) ? sinceMs : 0, limit: Number.isFinite(limit) ? Math.max(1, Math.min(500, limit)) : 200 });
1019
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...listed });
1020
+ }
1021
+ if (method === 'GET' && action === 'unread') return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ok: true, ...this.state.notifications.unread({ projectId, reader }) });
1022
+ if (method === 'POST' && action === 'read') {
1023
+ const body = takeJson();
1024
+ const marked = this.state.notifications.markRead({ projectId, reader, watermarkMs: body?.watermark_ms });
1025
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...marked });
1026
+ }
1027
+ throw new ProtocolError('BAD_REQUEST', 'notifications accept GET, GET /unread or POST /read', { method, path });
1028
+ }
1029
+
1030
+ /**
1031
+ * v1.3.14-beta.4 (collaboration model): a project-scoped SSE stream.
1032
+ *
1033
+ * `ready` names the project and its last event seq, `since_seq` replays the room events a client missed, then every new
1034
+ * room event is pushed as `project.event`. Clients in the same project therefore apply the same ordered events instead
1035
+ * of each polling a different view.
1036
+ */
1037
+ const projectStreamMatch = /^\/v1\/projects\/([^/]+)\/stream$/.exec(path);
1038
+ if (projectStreamMatch !== null && method === 'GET') {
1039
+ const projectId = decodeURIComponent(projectStreamMatch[1]);
1040
+ const token = bearerToken(req);
1041
+ const caller = token === null ? null : this.state.authenticate(token);
1042
+ if (caller === null) throw new ProtocolError('UNAUTHORIZED', 'project events need a paired device token');
1043
+ const snapshot = this.state.projectSnapshot(projectId);
1044
+ if (snapshot.project.project_id !== this.state.defaultProjectId) {
1045
+ const member = this.state.projects.memberOf({ projectId, member: { kind: 'machine', id: caller.machine_id } });
1046
+ if (member === null) throw new ProtocolError('NOT_FOUND', 'unknown project', { project_id: projectId });
1047
+ }
1048
+ const sinceRaw = url.searchParams.get('since_seq');
1049
+ const sinceSeq = sinceRaw !== null && /^[0-9]+$/.test(sinceRaw) ? Number(sinceRaw) : 0;
1050
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
1051
+ const frame = (entry) => {
1052
+ try { res.write('id: ' + entry.seq + '\nevent: project.event\ndata: ' + JSON.stringify({ project_id: entry.project_id, seq: entry.seq, type: entry.type, payload: entry.payload }) + '\n\n'); } catch { /* closed */ }
1053
+ };
1054
+ const replayed = this.state.projects.events({ projectId, sinceSeq });
1055
+ res.write('event: ready\ndata: ' + JSON.stringify({ protocol_version: PROTOCOL_VERSION, project_id: projectId, last_event_seq: replayed.next_seq }) + '\n\n');
1056
+ for (const entry of replayed.events) frame(entry);
1057
+ const unsubscribe = this.state.onProjectEvent((entry) => { if (entry.project_id === projectId) frame(entry); });
1058
+ const keepalive = setInterval(() => { try { res.write(': keepalive\n\n'); } catch { /* closed */ } }, this.keepaliveMs);
1059
+ let closed = false;
1060
+ const done = () => { if (closed) return; closed = true; clearInterval(keepalive); unsubscribe(); try { res.end(); } catch { /* already closed */ } };
1061
+ req.on('close', done);
1062
+ res.on('close', done);
1063
+ return true;
1064
+ }
1065
+
849
1066
  /* ---- everything below requires `Authorization: Bearer <device_token>` ---- */
850
1067
  const token = bearerToken(req);
851
1068
  const device = token ? this.state.authenticate(token) : null;
@@ -939,6 +1156,7 @@ export class RelayServer {
939
1156
  this.enforceSignature(req, { method, signedPath, body: rawBody });
940
1157
 
941
1158
  /* ---- v1.2.5: members and read watermarks ---- */
1159
+
942
1160
  if (method === 'GET' && path === '/v1/fleet/members') {
943
1161
  return sendJson(res, 200, {
944
1162
  protocol_version: PROTOCOL_VERSION,