@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"pack": "node scripts/pack.mjs",
|
|
65
65
|
"reachability": "node scripts/reachability-check.mjs",
|
|
66
66
|
"reachability:classify": "node scripts/reachability-classify.mjs",
|
|
67
|
-
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs",
|
|
67
|
+
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs",
|
|
68
68
|
"test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
|
|
69
69
|
"test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
|
|
70
70
|
"test:account-join": "node --test --test-force-exit test/account-join.test.mjs",
|
|
@@ -159,7 +159,9 @@
|
|
|
159
159
|
"test:outbox": "node --test --test-force-exit test/outbox.test.mjs",
|
|
160
160
|
"test:metrics": "node --test --test-force-exit test/metrics.test.mjs",
|
|
161
161
|
"test:reliability-metrics": "node --test --test-force-exit test/metrics.test.mjs",
|
|
162
|
-
"test:evidence": "node --test --test-force-exit test/evidence.test.mjs"
|
|
162
|
+
"test:evidence": "node --test --test-force-exit test/evidence.test.mjs",
|
|
163
|
+
"test:grant-issue": "node --test --test-force-exit test/grant-issue.test.mjs",
|
|
164
|
+
"test:project-refs": "node --test --test-force-exit test/project-refs.test.mjs"
|
|
163
165
|
},
|
|
164
166
|
"devDependencies": {
|
|
165
167
|
"@eslint/js": "^10.0.1",
|
|
@@ -196,7 +198,8 @@
|
|
|
196
198
|
"w2m-localside": "bin/w2m-localside.mjs",
|
|
197
199
|
"w2m-mcp": "bin/w2m-mcp.mjs",
|
|
198
200
|
"w2m-rabbit": "bin/w2m-rabbit.mjs",
|
|
199
|
-
"w2m-stun": "bin/w2m-stun.mjs"
|
|
201
|
+
"w2m-stun": "bin/w2m-stun.mjs",
|
|
202
|
+
"w2m-grant": "./bin/w2m-grant.mjs"
|
|
200
203
|
},
|
|
201
204
|
"disclosure": {
|
|
202
205
|
"cloud": true,
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "twinsearth-w2m-dsh",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.4.1",
|
|
5
5
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
package/src/agent/agent.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/agent/grant-gate.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/agent/worktree.mjs
CHANGED
|
@@ -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];
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
277
|
+
const PLUGIN_VERSION = '1.4.1';
|
|
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
|
+
}
|
package/src/relay/server.mjs
CHANGED
|
@@ -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,
|