@michelj/context-guard 0.4.4 → 0.6.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/Coordinator.md +88 -0
- package/Executor.md +53 -0
- package/README.md +72 -102
- package/README.zh-CN.md +72 -102
- package/SKILL.md +26 -33
- package/THIRD_PARTY_NOTICES.md +47 -0
- package/Tester.md +53 -0
- package/bin/build-runtime.mjs +96 -0
- package/bin/context-guard-skill.js +287 -69
- package/bin/postinstall.js +1 -1
- package/hooks.json +80 -4
- package/licenses/JSONParse-MIT.txt +24 -0
- package/licenses/Marked-MIT.txt +44 -0
- package/licenses/Portless-Apache-2.0.txt +201 -0
- package/package.json +31 -5
- package/prototype/LICENSES/Marked-MIT.txt +44 -0
- package/prototype/LICENSES/Ready-redistribution.txt +14 -0
- package/prototype/attachments.mjs +75 -0
- package/prototype/coordinator-markdown.mjs +283 -0
- package/prototype/coordinator-working-blot.mjs +124 -0
- package/prototype/vendor/marked.mjs +2189 -0
- package/prototype/workbench-app.js +5197 -0
- package/prototype/workbench-data.js +33 -0
- package/prototype/workbench-sync.mjs +898 -0
- package/prototype/workbench.css +1050 -0
- package/prototype/workbench.html +139 -4861
- package/prototype/working-blot-atlas.png +0 -0
- package/references/agent-handoff.md +40 -0
- package/references/claude-runtime.md +120 -0
- package/references/cloud-sync-interface.md +66 -0
- package/references/design-current.md +14 -0
- package/references/map-mount.md +41 -0
- package/references/map-read.md +50 -0
- package/references/memory-definition.md +120 -0
- package/references/memory-filesystem-v2/Bug.en.md +162 -0
- package/references/memory-filesystem-v2/Bug.md +162 -0
- package/references/memory-filesystem-v2/Bug_Coordinater.md +8 -0
- package/references/memory-filesystem-v2/Bug_Executor.md +8 -0
- package/references/memory-filesystem-v2/Bug_Tester.md +7 -0
- package/references/memory-filesystem-v2/Idea.en.md +36 -0
- package/references/memory-filesystem-v2/Idea.md +36 -0
- package/references/memory-filesystem-v2/Node_Module_Index.en.md +88 -0
- package/references/memory-filesystem-v2/Node_Module_Index.md +88 -0
- package/references/memory-filesystem-v2/README.md +60 -0
- package/references/memory-filesystem-v2/Todo.en.md +137 -0
- package/references/memory-filesystem-v2/Todo.md +137 -0
- package/references/memory-filesystem-v2/Todo_Coordinater.md +7 -0
- package/references/memory-filesystem-v2/Todo_Executor.md +7 -0
- package/references/memory-filesystem-v2/Todo_Tester.md +7 -0
- package/references/named-workbench.md +124 -0
- package/references/plan-review.md +12 -0
- package/references/server-memory.md +276 -0
- package/references/test-check.md +7 -0
- package/references/user-reply.md +38 -0
- package/references/workbench-interface.md +531 -0
- package/roles.md +13 -0
- package/scripts/context_guard.py +1163 -321
- package/scripts/context_guard_hook.py +1864 -63
- package/scripts/map_owns.py +68 -138
- package/scripts/shared/LICENSES/JSONParse-MIT.txt +24 -0
- package/scripts/shared/filesystem-v2.mjs +430 -0
- package/scripts/shared/io.mjs +117 -0
- package/scripts/shared/map-model.mjs +506 -0
- package/scripts/shared/memory-schema.mjs +13 -0
- package/scripts/shared/protocol-blobs.mjs +112 -0
- package/scripts/shared/protocol-map.mjs +146 -0
- package/scripts/shared/protocol-snapshots.mjs +84 -0
- package/scripts/shared/protocol-store.mjs +624 -0
- package/scripts/shared/protocol-workflow.mjs +226 -0
- package/scripts/shared/protocol.mjs +125 -0
- package/scripts/shared/vendor/jsonparse.cjs +413 -0
- package/scripts/workbench/access.mjs +496 -0
- package/scripts/workbench/attachments.mjs +92 -0
- package/scripts/workbench/browser-login.mjs +78 -0
- package/scripts/workbench/claude-runtime.mjs +372 -0
- package/scripts/workbench/cli.mjs +980 -0
- package/scripts/workbench/device-heartbeat.mjs +72 -0
- package/scripts/workbench/hook-status.mjs +38 -0
- package/scripts/workbench/inbox.mjs +155 -0
- package/scripts/workbench/journal.mjs +56 -0
- package/scripts/workbench/memory-merge.mjs +65 -0
- package/scripts/workbench/memory.mjs +252 -0
- package/scripts/workbench/named-proxy.mjs +108 -0
- package/scripts/workbench/named.mjs +152 -0
- package/scripts/workbench/portless-routes.mjs +51 -0
- package/scripts/workbench/project.mjs +327 -0
- package/scripts/workbench/projections.mjs +68 -0
- package/scripts/workbench/protocol-client.mjs +165 -0
- package/scripts/workbench/protocol-delivery.mjs +133 -0
- package/scripts/workbench/protocol-device.mjs +316 -0
- package/scripts/workbench/protocol-events.mjs +53 -0
- package/scripts/workbench/protocol-repository.mjs +58 -0
- package/scripts/workbench/reconcile.mjs +244 -0
- package/scripts/workbench/registry.mjs +111 -0
- package/scripts/workbench/runtime.mjs +54 -0
- package/scripts/workbench/server.mjs +1171 -0
- package/scripts/workbench/store.mjs +243 -0
- package/scripts/workbench/sync-coordinator.mjs +518 -0
- package/scripts/workbench/sync.mjs +86 -0
- package/references/bug-record-template.md +0 -37
- package/references/context-template.md +0 -19
|
@@ -0,0 +1,1171 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { ClaudeRuntime } from './claude-runtime.mjs';
|
|
9
|
+
import { MapStore } from './store.mjs';
|
|
10
|
+
import { Access, token } from './access.mjs';
|
|
11
|
+
import { atomicWrite, encode, readJSON, pause, hash } from '../shared/io.mjs';
|
|
12
|
+
import { generateProjections } from './projections.mjs';
|
|
13
|
+
import { resolveProject, ensureProjectBinding, refreshMain, sessionBinding, sessionBindingsPath } from './project.mjs';
|
|
14
|
+
import { memoryRequest, sessionMemoryDir, memoryConfigPath } from './memory.mjs';
|
|
15
|
+
import { MemorySyncCoordinator } from './sync-coordinator.mjs';
|
|
16
|
+
import { inspectRetiredSync } from './sync.mjs';
|
|
17
|
+
import { runtimeIdentity } from './runtime.mjs';
|
|
18
|
+
import { Attachments } from './attachments.mjs';
|
|
19
|
+
import { ProtocolStore } from '../shared/protocol-store.mjs';
|
|
20
|
+
import { ProtocolBlobs, serveBlob } from '../shared/protocol-blobs.mjs';
|
|
21
|
+
import { workflowTypes } from '../shared/protocol-workflow.mjs';
|
|
22
|
+
import { ProtocolDelivery, controlReport, executionNotifications, executionPrompt } from './protocol-delivery.mjs';
|
|
23
|
+
import { DeviceConnection } from './protocol-device.mjs';
|
|
24
|
+
import { ensureNamedProxy } from './named.mjs';
|
|
25
|
+
import { defaultDirectoryAvailability, registeredProject, rememberProject } from './registry.mjs';
|
|
26
|
+
import { WorkbenchSnapshots } from '../shared/protocol-snapshots.mjs';
|
|
27
|
+
import { ProtocolMap, verifyChangeReferences } from '../shared/protocol-map.mjs';
|
|
28
|
+
import { lookupRepository } from './protocol-repository.mjs';
|
|
29
|
+
import { messageHandler, sendMessage } from './protocol-client.mjs';
|
|
30
|
+
import { fail as protocolFail, validateMessage } from '../shared/protocol.mjs';
|
|
31
|
+
import { MapError, entries, validate, diffTrees, restoreSessionWorkItemOperations, scopeChangesToSession, scopeDocumentToSession } from '../shared/map-model.mjs';
|
|
32
|
+
export const skillRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
33
|
+
export const statePath = root => path.join(root, '.codex/context/private/workbench.json');
|
|
34
|
+
export const projectStatePath = project => project.kind === 'git' ? path.join(project.sharedDir, 'workbench.json') : statePath(project.worktreeRoot);
|
|
35
|
+
export const projectLockPath = project => project.kind === 'git' ? path.join(project.sharedDir, 'node-workbench.lock') : path.join(project.worktreeRoot, '.codex/context/private/node-workbench.lock');
|
|
36
|
+
const execFileAsync = promisify(execFile);
|
|
37
|
+
|
|
38
|
+
export async function reportVerifiedControl(store, principal, send, message) {
|
|
39
|
+
const execution = await store.activeExecution(principal, message.session);
|
|
40
|
+
const alreadyApplied = await store.controlReportApplied(principal, message);
|
|
41
|
+
if ((!alreadyApplied && execution?.taskId !== message.payload.taskId) ||
|
|
42
|
+
(message.payload.action === 'resume' && execution?.closed)) protocolFail('CONFLICT', 'Control does not match the current task');
|
|
43
|
+
const report = controlReport(message);
|
|
44
|
+
await send(report);
|
|
45
|
+
await store.receiveNotification(principal, report);
|
|
46
|
+
return report;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function missingCommitFields(input) {
|
|
50
|
+
const missing = [];
|
|
51
|
+
if (typeof input?.baseVersion !== 'string' || !input.baseVersion) missing.push('baseVersion');
|
|
52
|
+
if (!Array.isArray(input?.operations)) missing.push('operations');
|
|
53
|
+
return missing;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function prepareSessionCommit(store, input, actor, sessionId) {
|
|
57
|
+
if (!sessionId || !input?.recoveryOf) {
|
|
58
|
+
const missing = missingCommitFields(input);
|
|
59
|
+
if (missing.length) throw new MapError('INVALID_ARGUMENT', `Missing required field${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`);
|
|
60
|
+
return { input: sessionId ? { ...input, operations: restoreSessionWorkItemOperations(store.doc, input.operations, sessionId) } : input, actor };
|
|
61
|
+
}
|
|
62
|
+
const operation = Array.isArray(input.operations) && input.operations.length === 1 ? input.operations[0] : null;
|
|
63
|
+
const bugId = ['attach-bug', 'update-bug'].includes(operation?.type) ? operation.bug?.id || '' : '';
|
|
64
|
+
const expected = bugId ? `bug:${sessionId}:${bugId}` : '';
|
|
65
|
+
const prior = expected && input.recoveryOf === expected ? await store.operation(expected) : null;
|
|
66
|
+
const original = prior?.event;
|
|
67
|
+
const originalOperation = original?.operations?.find(item => item?.type === 'attach-bug' && item?.bug?.id === bugId);
|
|
68
|
+
const sameTarget = operation?.type === 'update-bug' || originalOperation?.id === operation?.id;
|
|
69
|
+
if (!prior?.result?.committed || original?.actor?.sessionId !== sessionId || !originalOperation || !sameTarget) {
|
|
70
|
+
throw new MapError('FORBIDDEN_RECOVERY', 'Bug recovery requires this Session\'s committed original attachment receipt', 403);
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
input: { ...input, operations: [{ type: 'recover-bug', id: originalOperation.id, bug: { ...originalOperation.bug, ...operation.bug } }] },
|
|
74
|
+
actor: { kind: 'recovery', sessionId, projectId: actor.projectId, worktreeId: actor.worktreeId },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function queueCodexMessage({ sessionId, message, root }, { run = execFileAsync, platform = process.platform } = {}) {
|
|
79
|
+
if (platform === 'darwin') {
|
|
80
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) throw Object.assign(new Error('Desktop delivery requires a Session UUID'), { code: 'INVALID_SESSION' });
|
|
81
|
+
try {
|
|
82
|
+
await run('/usr/bin/open', ['-g', `codex://threads/${sessionId}`], { cwd: root, timeout: 15000, maxBuffer: 65536 });
|
|
83
|
+
} catch {
|
|
84
|
+
// No task has been queued yet: opening can be retried safely, including
|
|
85
|
+
// after an opener timeout. Never turn this into uncertain task acceptance.
|
|
86
|
+
throw Object.assign(new Error('Could not request the existing desktop Session; no task was queued'), { code: 'DESKTOP_OPEN_FAILED' });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Opening requests a load; only the Session's started report proves execution.
|
|
90
|
+
// The durable native queue also covers loading that finishes after this call.
|
|
91
|
+
await run(process.env.CONTEXT_GUARD_CODEX_COMMAND || 'codex', ['queue', '--thread', sessionId, '--message', message], {
|
|
92
|
+
cwd: root,
|
|
93
|
+
encoding: 'utf8',
|
|
94
|
+
windowsHide: true,
|
|
95
|
+
timeout: 15000,
|
|
96
|
+
maxBuffer: 1024 * 1024,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A native Claude turn can be terminated by its timeout without emitting the
|
|
101
|
+
// Claude Interrupt hook. Keep the workflow authoritative by deriving one
|
|
102
|
+
// stable interruption report from the persisted delivery and current local
|
|
103
|
+
// execution. The DeviceConnection outbox makes retries idempotent.
|
|
104
|
+
export function interruptedTaskReport(session, execution, native) {
|
|
105
|
+
if (native?.status !== 'interrupted' || !native.deliveryId || !native.at || !execution?.taskId || !session?.id || !Number.isSafeInteger(session.generation)) return null;
|
|
106
|
+
return { v: 2, id: `interrupt:${session.id}:${native.deliveryId}`, type: 'task.report', session,
|
|
107
|
+
payload: { taskId: execution.taskId, stage: 'interrupted', data: { reason: native.error || 'Local Claude execution interrupted', occurredAt: native.at } } };
|
|
108
|
+
}
|
|
109
|
+
function loopbackJSON(target, { method = 'GET', headers = {}, body, timeout = 600, maxBytes = 1024 * 1024 } = {}) {
|
|
110
|
+
return new Promise(resolve => {
|
|
111
|
+
let settled = false;
|
|
112
|
+
const finish = value => { if (!settled) { settled = true; resolve(value); } };
|
|
113
|
+
let req;
|
|
114
|
+
try {
|
|
115
|
+
req = http.request(target, { method, headers: { Connection: 'close', ...headers }, agent: false }, res => {
|
|
116
|
+
const chunks = [];
|
|
117
|
+
let size = 0;
|
|
118
|
+
res.on('data', chunk => {
|
|
119
|
+
size += chunk.length;
|
|
120
|
+
if (size > maxBytes) {
|
|
121
|
+
finish({ ok: false, status: 502, value: { error: { code: 'RESPONSE_TOO_LARGE', message: 'Workbench response exceeds the client size limit' } } });
|
|
122
|
+
req.destroy();
|
|
123
|
+
}
|
|
124
|
+
else chunks.push(chunk);
|
|
125
|
+
});
|
|
126
|
+
res.on('end', () => {
|
|
127
|
+
try { finish({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, value: JSON.parse(Buffer.concat(chunks).toString('utf8')) }); }
|
|
128
|
+
catch { finish(null); }
|
|
129
|
+
});
|
|
130
|
+
res.on('error', () => finish(null));
|
|
131
|
+
});
|
|
132
|
+
req.setTimeout(timeout, () => { req.destroy(); finish(null); });
|
|
133
|
+
req.on('error', () => finish(null));
|
|
134
|
+
if (body !== undefined) req.write(body);
|
|
135
|
+
req.end();
|
|
136
|
+
} catch { req?.destroy(); finish(null); }
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
export async function health(state) {
|
|
140
|
+
// A service may disappear between repeated migration probes. Node 24's
|
|
141
|
+
// pooled fetch/undici path can throw setTypeOfService EINVAL outside the
|
|
142
|
+
// request promise on macOS, so lifecycle control uses an unpooled socket.
|
|
143
|
+
const result = await loopbackJSON(new URL('/__context_guard/health', state.url));
|
|
144
|
+
return result?.ok ? result.value : null;
|
|
145
|
+
}
|
|
146
|
+
export { loopbackJSON };
|
|
147
|
+
export async function startServer({ root, port = 8877, host = '127.0.0.1', fault, messageQueue = queueCodexMessage, repositoryLookup = lookupRepository } = {}) {
|
|
148
|
+
if (!['127.0.0.1', 'localhost'].includes(host)) throw new MapError('INVALID_HOST', 'Workbench only listens on loopback');
|
|
149
|
+
const directory = await defaultDirectoryAvailability();
|
|
150
|
+
if (directory.unavailable) {
|
|
151
|
+
const which = directory.overridden ? 'configured directory' : 'default directory';
|
|
152
|
+
throw new MapError('START_FAILED', `The ${which} ${directory.path} is unavailable (${directory.code})`, 503);
|
|
153
|
+
}
|
|
154
|
+
root = await fs.realpath(path.resolve(root));
|
|
155
|
+
let project = await resolveProject(root);
|
|
156
|
+
await inspectRetiredSync(project);
|
|
157
|
+
project = await ensureProjectBinding(project);
|
|
158
|
+
const claudeRuntime = new ClaudeRuntime(path.join(project.sharedDir, 'claude-runtime'));
|
|
159
|
+
const ctx = path.join(root, '.codex/context'), lock = projectLockPath(project), sharedState = projectStatePath(project);
|
|
160
|
+
const namedFile = project.kind === 'git' ? path.join(project.sharedDir, 'named-entry.json') : path.join(ctx, 'private/named-entry.json');
|
|
161
|
+
let namedEntry = await readJSON(namedFile, null), openClaimed = false;
|
|
162
|
+
const validNamedOrigin = value => /^http:\/\/[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.localhost:[1-9][0-9]{0,4}$/.test(value || '') && Number(new URL(value).port) > 0 && Number(new URL(value).port) <= 65535;
|
|
163
|
+
if (namedEntry && (!validNamedOrigin(namedEntry.origin) || typeof namedEntry.proxyToken !== 'string' || namedEntry.proxyToken.length < 32)) throw new MapError('INVALID_ORIGIN', 'Invalid saved named entry; configuration preserved');
|
|
164
|
+
await fs.mkdir(path.dirname(lock), { recursive: true });
|
|
165
|
+
const instance = token();
|
|
166
|
+
for (let i = 0; i < 2; i++) {
|
|
167
|
+
try { const h = await fs.open(lock, 'wx', 0o600); await h.writeFile(encode({ pid: process.pid, instance })); await h.close(); break; }
|
|
168
|
+
catch (e) {
|
|
169
|
+
if (e.code !== 'EEXIST') throw e;
|
|
170
|
+
const owner = await readJSON(lock, null);
|
|
171
|
+
if (!owner?.pid) throw new MapError('STARTING', 'Another instance is starting; retry shortly', 409);
|
|
172
|
+
let alive = true; try { process.kill(owner.pid, 0); } catch (err) { if (err.code === 'ESRCH') alive = false; }
|
|
173
|
+
if (alive) throw new MapError('ALREADY_RUNNING', 'Project already has a Node service', 409);
|
|
174
|
+
await fs.unlink(lock);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const adminToken = token(), humanToken = token(), agentTokens = new Map(), peers = new Map();
|
|
178
|
+
const protocolStore = new ProtocolStore(path.join(project.sharedDir, 'interface-v2'));
|
|
179
|
+
const protocolBlobs = new ProtocolBlobs(path.join(project.sharedDir, 'interface-v2', 'blobs'));
|
|
180
|
+
const protocolSnapshots = new WorkbenchSnapshots(path.join(project.sharedDir, 'interface-v2', 'snapshots'));
|
|
181
|
+
const protocolMap = new ProtocolMap(path.join(project.sharedDir, 'interface-v2', 'map-intents'));
|
|
182
|
+
const backendPrincipal = { repositoryId: project.projectId, deviceId: project.projectId, agentId: project.projectId, role: 'device' };
|
|
183
|
+
let device;
|
|
184
|
+
let deviceHeartbeatTimer = null, deviceHeartbeatRunning = null, deviceHeartbeatStopping = false;
|
|
185
|
+
const ciConnections = new Map();
|
|
186
|
+
const ciRetries = new Map();
|
|
187
|
+
const taskDelivery = new ProtocolDelivery(path.join(project.sharedDir, 'interface-v2', 'task-deliveries'), {
|
|
188
|
+
codex: input => messageQueue({ sessionId: input.sessionId, message: input.message, root: input.root }),
|
|
189
|
+
claude: { deliver: input => claudeRuntime.deliverGuidance(input), received: input => claudeRuntime.receivedGuidance(input) },
|
|
190
|
+
});
|
|
191
|
+
const reportControl = message => reportVerifiedControl(protocolStore, backendPrincipal, report => device.send(report), message);
|
|
192
|
+
const resumeAcceptedByHost = async message => {
|
|
193
|
+
const recovery = await readJSON(claudeRuntime.jobFile(message.session.id, `recovery:cloud-${hash(message.id)}`), null);
|
|
194
|
+
if (recovery && recovery.state !== 'failed') return true;
|
|
195
|
+
const delivery = await readJSON(path.join(taskDelivery.directory,
|
|
196
|
+
`${hash(`${message.session.generation}:${message.id}:resume`)}.json`), null);
|
|
197
|
+
return delivery?.state === 'received';
|
|
198
|
+
};
|
|
199
|
+
const acceptResumeControl = async (message, session) => {
|
|
200
|
+
if (await protocolStore.resumeControlApplied(backendPrincipal, message)) return true;
|
|
201
|
+
if (!await resumeAcceptedByHost(message)) {
|
|
202
|
+
const taskPrompt = await executionPrompt(message, (ref, version) => device.send({ v: 2, id: randomUUID(), type: 'object.read', session: message.session, payload: { ref, version } }));
|
|
203
|
+
const prompt = `${taskPrompt}\n宿主绑定的当前工作树:${session.worktreeRoot || root}\n所有 Context Guard 命令的 --root 使用这个工作树,不使用模板或主仓库目录。mainVersion 是记忆版本,不是 Git SHA。`;
|
|
204
|
+
const native = await claudeRuntime.status(session.id);
|
|
205
|
+
if (native.status === 'interrupted' && native.deliveryId) {
|
|
206
|
+
await claudeRuntime.recover(session.id, { operationId: `cloud-${hash(message.id)}`, deliveryId: native.deliveryId, message: prompt }, session.worktreeRoot || root);
|
|
207
|
+
} else if (native.status === 'stopped') {
|
|
208
|
+
await taskDelivery.deliver({ id: `${message.session.generation}:${message.id}:resume`, platform: 'claude', sessionId: session.id,
|
|
209
|
+
root: session.worktreeRoot || root, message: prompt });
|
|
210
|
+
} else return false;
|
|
211
|
+
}
|
|
212
|
+
await reportControl(message);
|
|
213
|
+
return true;
|
|
214
|
+
};
|
|
215
|
+
let taskDeliveryRetry = null;
|
|
216
|
+
const ciChannel = (sessionId, connection) => {
|
|
217
|
+
if (!ciConnections.has(sessionId) || ciConnections.get(sessionId).origin !== connection.origin) {
|
|
218
|
+
const channel = new DeviceConnection({ directory: path.join(project.sharedDir, 'interface-v2', 'ci', sessionId), origin: connection.origin, allowLoopback: true,
|
|
219
|
+
transport: (origin, credential, message, options) => sendMessage(origin, credential, message, { ...options, ciSessionId: sessionId }) });
|
|
220
|
+
channel.file = connection.file; // Device login is shared; this is not another heartbeat service.
|
|
221
|
+
ciConnections.set(sessionId, channel);
|
|
222
|
+
}
|
|
223
|
+
return ciConnections.get(sessionId);
|
|
224
|
+
};
|
|
225
|
+
let deviceServiceCheck = null, deviceServiceCheckedAt = 0;
|
|
226
|
+
const ensureDeviceService = async () => {
|
|
227
|
+
if (deviceServiceCheck) return deviceServiceCheck;
|
|
228
|
+
if (Date.now() - deviceServiceCheckedAt < 10000) return;
|
|
229
|
+
deviceServiceCheck = (async () => {
|
|
230
|
+
const state = await readJSON(sharedState, null);
|
|
231
|
+
if (state?.instance !== instance || !await device?.connected()) return;
|
|
232
|
+
if ((await registeredProject(project))?.runtime?.instance !== instance) await rememberProject(project, { state });
|
|
233
|
+
await ensureNamedProxy();
|
|
234
|
+
deviceServiceCheckedAt = Date.now();
|
|
235
|
+
})().finally(() => { deviceServiceCheck = null; });
|
|
236
|
+
return deviceServiceCheck;
|
|
237
|
+
};
|
|
238
|
+
const projectDevice = async () => {
|
|
239
|
+
const config = await readJSON(memoryConfigPath(project), null);
|
|
240
|
+
if (!config?.url) return null;
|
|
241
|
+
if (!device || device.origin !== config.url) {
|
|
242
|
+
clearInterval(deviceHeartbeatTimer); deviceHeartbeatTimer = null;
|
|
243
|
+
await deviceHeartbeatRunning;
|
|
244
|
+
await device?.close();
|
|
245
|
+
device = new DeviceConnection({ directory: path.join(project.sharedDir, 'interface-v2'), origin: config.url, allowLoopback: true });
|
|
246
|
+
}
|
|
247
|
+
if (await device.connected()) device.start({
|
|
248
|
+
onSessionCreations: async requests => {
|
|
249
|
+
if (!Array.isArray(requests) || requests.length > 20) protocolFail('INVALID_ARGUMENT', 'Bounded Session creation batch required');
|
|
250
|
+
for (const request of requests) {
|
|
251
|
+
try {
|
|
252
|
+
if (!access.binding(request.templateSessionId)) protocolFail('FORBIDDEN', 'The template is no longer bound to this backend');
|
|
253
|
+
const created = await claudeRuntime.provision(request, { baseRef: project.mainRef, start: false });
|
|
254
|
+
await registerSession({ sessionId: request.sessionId, worktreeRoot: created.root }, { connection: device });
|
|
255
|
+
} catch (error) {
|
|
256
|
+
device.lastError = error.code || 'SESSION_CREATION_FAILED';
|
|
257
|
+
await device.recordCreationFailure(request.id, error.code);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
sessions: async () => {
|
|
262
|
+
if (!taskDeliveryRetry) taskDeliveryRetry = taskDelivery.retryBusy(async input => {
|
|
263
|
+
if (input.platform !== 'claude' || !access.binding(input.sessionId)) return false;
|
|
264
|
+
const plan = /^Context Guard:Plan (\S+)@(\S+) 审核/.exec(input.message);
|
|
265
|
+
if (!plan && input.deliveryType !== 'task.message') return false;
|
|
266
|
+
const binding = await protocolStore.registeredBinding(backendPrincipal, input.sessionId);
|
|
267
|
+
const execution = binding && await protocolStore.activeExecution(backendPrincipal, { id: input.sessionId, generation: binding.generation });
|
|
268
|
+
if (input.deliveryType === 'task.message') {
|
|
269
|
+
if (execution?.closed || execution?.taskId !== input.taskId ||
|
|
270
|
+
input.planRef && (execution.plan?.ref !== input.planRef || execution.plan.version !== input.planVersion)) return false;
|
|
271
|
+
} else if (execution?.closed || execution?.plan?.ref !== plan[1] || execution.plan.version !== plan[2]) return false;
|
|
272
|
+
const status = (await claudeRuntime.status(input.sessionId)).status;
|
|
273
|
+
return status === 'stopped' || input.deliveryType === 'task.message' && status === 'interrupted';
|
|
274
|
+
}).catch(error => { device.lastError = error.code || 'TASK_DELIVERY_RETRY_FAILED'; })
|
|
275
|
+
.finally(() => { taskDeliveryRetry = null; });
|
|
276
|
+
const registered = [], identities = new Map((await access.sessionRegistry()).map(item => [item.id, item]));
|
|
277
|
+
const heads = await protocolStore.queueHeads(backendPrincipal), queued = new Set(heads.map(head => head.session.id));
|
|
278
|
+
// A freshly provisioned Session has no queued task yet: Cloud waits for
|
|
279
|
+
// its stopped heartbeat before dispatching. Include every registered
|
|
280
|
+
// binding so direct registration cannot deadlock on an empty queue.
|
|
281
|
+
for (const identity of identities.values()) {
|
|
282
|
+
if (queued.has(identity.id) || !access.binding(identity.id)) continue;
|
|
283
|
+
const binding = await protocolStore.registeredBinding(backendPrincipal, identity.id).catch(() => null);
|
|
284
|
+
if (binding) heads.push({ session: { id: identity.id, generation: binding.generation } });
|
|
285
|
+
}
|
|
286
|
+
for (const head of heads) {
|
|
287
|
+
if (!access.binding(head.session.id)) continue;
|
|
288
|
+
try {
|
|
289
|
+
if (await device.bindingReady(await protocolStore.registeredBinding(backendPrincipal, head.session.id))) {
|
|
290
|
+
const identity = identities.get(head.session.id), heartbeat = { ...head.session, ackedSeq: 0 };
|
|
291
|
+
if (identity?.name) heartbeat.name = identity.name;
|
|
292
|
+
if (identity?.platform && identity.platform !== 'unknown') heartbeat.platform = identity.platform;
|
|
293
|
+
const native = identity?.platform === 'claude' ? await claudeRuntime.status(head.session.id) : null;
|
|
294
|
+
if (native?.role === 'ci') ciChannel(head.session.id, device);
|
|
295
|
+
if (native?.name) heartbeat.name = native.name;
|
|
296
|
+
const execution = await protocolStore.activeExecution(backendPrincipal, head.session).catch(() => null);
|
|
297
|
+
for (const control of await protocolStore.pendingControls(backendPrincipal, head.session, 'complete')) {
|
|
298
|
+
if (execution?.taskId !== control.payload.taskId) continue;
|
|
299
|
+
await reportControl(control).catch(error => { device.lastError = error.code || 'CLOSE_REPORT_FAILED'; });
|
|
300
|
+
}
|
|
301
|
+
let resumed = false;
|
|
302
|
+
if (native?.role === 'executor' && execution && !execution.closed) {
|
|
303
|
+
for (const control of await protocolStore.pendingControls(backendPrincipal, head.session, 'resume')) {
|
|
304
|
+
if (control.payload.taskId !== execution.taskId) continue;
|
|
305
|
+
resumed = await acceptResumeControl(control, identity).catch(error => {
|
|
306
|
+
device.lastError = error.code || 'RESUME_REPORT_FAILED'; return false;
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (native?.role === 'executor' && native.status === 'interrupted' && !resumed) {
|
|
311
|
+
const report = interruptedTaskReport(head.session, execution, native);
|
|
312
|
+
if (report) await device.send(report).catch(error => { device.lastError = error.code || 'INTERRUPTION_REPORT_FAILED'; });
|
|
313
|
+
}
|
|
314
|
+
heartbeat.execution = native?.configured && Date.parse(native.at) >= (Date.parse(identity?.statusSeen) || 0)
|
|
315
|
+
? { status: native.status, at: native.at }
|
|
316
|
+
: { status: ['active', 'stopped'].includes(identity?.status) ? identity.status : 'unknown', at: identity?.statusSeen || '' };
|
|
317
|
+
registered.push(heartbeat);
|
|
318
|
+
}
|
|
319
|
+
} catch (error) { device.lastError = error.code || 'UNAVAILABLE'; }
|
|
320
|
+
}
|
|
321
|
+
return registered;
|
|
322
|
+
},
|
|
323
|
+
apply: async message => {
|
|
324
|
+
if (!access.binding(message.session.id)) protocolFail('FORBIDDEN', 'Session registration was revoked');
|
|
325
|
+
const result = (await protocolStore.receiveNotification(backendPrincipal, message)).data;
|
|
326
|
+
// A confirmed resumed report is stronger than a lost native acceptance
|
|
327
|
+
// receipt. Replayed controls must not start another model turn.
|
|
328
|
+
if (await protocolStore.resumeControlApplied(backendPrincipal, message)) return { ...result, deliveryState: 'stored', reason: 'Resume control was already applied' };
|
|
329
|
+
if (executionNotifications.has(message.type) || message.type === 'review.result' && message.payload.kind === 'plan') {
|
|
330
|
+
const session = (await access.sessionRegistry()).find(item => item.id === message.session.id);
|
|
331
|
+
if (!session) protocolFail('FORBIDDEN', 'Host Session is unavailable');
|
|
332
|
+
if (!['codex', 'claude'].includes(session.platform)) return result;
|
|
333
|
+
const target = await storeFor(project.kind === 'git' ? `session:${session.id}` : 'main');
|
|
334
|
+
if (message.type === 'task.assign' && message.payload.nodeIds.some(id => !access.grants(session.id, target.doc, 'read').includes(id))) protocolFail('FORBIDDEN', 'Assigned node access was revoked');
|
|
335
|
+
try {
|
|
336
|
+
if (message.type === 'task.control' && message.payload.action === 'complete') {
|
|
337
|
+
await reportControl(message);
|
|
338
|
+
return { ...result, deliveryState: 'received' };
|
|
339
|
+
}
|
|
340
|
+
if (session.platform === 'claude' && message.type === 'task.control' && message.payload.action === 'resume') {
|
|
341
|
+
const execution = await protocolStore.activeExecution(backendPrincipal, message.session);
|
|
342
|
+
if (execution?.taskId !== message.payload.taskId || execution.closed) protocolFail('CONFLICT', 'Resume control does not match an active task');
|
|
343
|
+
return await acceptResumeControl(message, session)
|
|
344
|
+
? { ...result, deliveryState: 'received' }
|
|
345
|
+
: { ...result, deliveryState: 'stored', reason: 'Native turn is busy; resume control is queued' };
|
|
346
|
+
}
|
|
347
|
+
const taskPrompt = await executionPrompt(message, (ref, version) => device.send({ v: 2, id: randomUUID(), type: 'object.read', session: message.session, payload: { ref, version } }));
|
|
348
|
+
const prompt = `${taskPrompt}\n宿主绑定的当前工作树:${session.worktreeRoot || root}\n所有 Context Guard 命令的 --root 使用这个工作树,不使用模板或主仓库目录。mainVersion 是记忆版本,不是 Git SHA。`;
|
|
349
|
+
const ci = message.type === 'ci.request' ? await claudeRuntime.ciReceiver(session.id) : null;
|
|
350
|
+
if (ci && (!access.binding(ci.sessionId) || access.binding(ci.sessionId).worktreeRoot !== ci.root || ci.root === session.worktreeRoot)) protocolFail('FORBIDDEN', 'CI receiver binding is not independent');
|
|
351
|
+
await taskDelivery.deliver({ id: `${message.session.generation}:${message.id}`, platform: ci ? 'claude' : session.platform, sessionId: ci?.sessionId || session.id, root: ci?.root || session.worktreeRoot || root, message: prompt,
|
|
352
|
+
...(message.type === 'task.message' ? { deliveryType: 'task.message', taskId: message.payload.taskId,
|
|
353
|
+
...(message.payload.planRef ? { planRef: message.payload.planRef, planVersion: message.payload.planVersion } : {}) } : {}),
|
|
354
|
+
...(ci ? { execution: { session: message.session, taskId: message.payload.taskId, sourceSha: message.payload.sourceSha, ciTodoRef: message.payload.ciTodoRef, references: message.payload.references || {} } } : {}) });
|
|
355
|
+
return { ...result, deliveryState: 'received' };
|
|
356
|
+
} catch (error) {
|
|
357
|
+
if (error?.details?.deliveryState === 'uncertain') return { ...result, deliveryState: 'uncertain', reason: 'Native host acceptance could not be confirmed' };
|
|
358
|
+
if (error?.details?.deliveryState === 'failed') {
|
|
359
|
+
// The notification is already durable in the local protocol
|
|
360
|
+
// journal. A busy/interrupted native model must not head-of-line
|
|
361
|
+
// block later Plan, handoff or CI messages for the whole project.
|
|
362
|
+
return { ...result, deliveryState: 'stored', reason: 'Native host is unavailable; continue protocol synchronization' };
|
|
363
|
+
}
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return { ...result, deliveryState: 'stored' };
|
|
368
|
+
},
|
|
369
|
+
onSession: head => {
|
|
370
|
+
syncCoordinators.get(`session:${head.id}`)?.projectHeartbeat(head);
|
|
371
|
+
const ci = ciConnections.get(head.id);
|
|
372
|
+
if (ci && !ciRetries.has(head.id)) ciRetries.set(head.id, ci.retryPending().catch(error => { device.lastError = error.code || 'CI_RETRY_FAILED'; }).finally(() => ciRetries.delete(head.id)));
|
|
373
|
+
},
|
|
374
|
+
onError: (error, source) => {
|
|
375
|
+
device.lastError = error.code || 'UNAVAILABLE';
|
|
376
|
+
if (source === 'heartbeat' && !(error instanceof AggregateError)) for (const [view, coordinator] of syncCoordinators) if ((!error.details?.sessionId || view === `session:${error.details.sessionId}`) && coordinator.managed && !coordinator.status.conflict) {
|
|
377
|
+
coordinator.update({ status: error.code === 'UNAUTHORIZED' ? 'error' : 'offline', error: device.lastError });
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
if (await device.connected() && !deviceHeartbeatTimer) {
|
|
382
|
+
const beat = () => {
|
|
383
|
+
if (deviceHeartbeatRunning || deviceHeartbeatStopping) return;
|
|
384
|
+
deviceHeartbeatRunning = (async () => {
|
|
385
|
+
const request = await device.runtime.prepare();
|
|
386
|
+
const response = await fetch(new URL('/api/v2/heartbeat', request.origin), {
|
|
387
|
+
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
|
|
388
|
+
headers: { 'Content-Type': 'application/json' },
|
|
389
|
+
body: JSON.stringify([{ credential: request.credential, message: request.message }]),
|
|
390
|
+
});
|
|
391
|
+
if (!response.ok || !response.headers.get('content-type')?.includes('application/json')) protocolFail('UNAVAILABLE', 'Device heartbeat endpoint unavailable');
|
|
392
|
+
const replies = await response.json(), reply = Array.isArray(replies) && replies.find(item => item?.id === request.message.id);
|
|
393
|
+
if (!reply || typeof reply.ok !== 'boolean') protocolFail('UNAVAILABLE', 'Device heartbeat receipt is invalid');
|
|
394
|
+
device.runtime.accept(reply);
|
|
395
|
+
device.lastError = null;
|
|
396
|
+
})().catch(error => { device.lastError = error.code || 'UNAVAILABLE'; })
|
|
397
|
+
.finally(() => { deviceHeartbeatRunning = null; });
|
|
398
|
+
};
|
|
399
|
+
deviceHeartbeatTimer = setInterval(beat, 5000); deviceHeartbeatTimer.unref(); beat();
|
|
400
|
+
}
|
|
401
|
+
await ensureDeviceService();
|
|
402
|
+
return device;
|
|
403
|
+
};
|
|
404
|
+
const access = await new Access(root, project.kind === 'git' ? {
|
|
405
|
+
file: path.join(project.sharedDir, 'workbench-access.json'),
|
|
406
|
+
bindingsFile: sessionBindingsPath(project),
|
|
407
|
+
} : {}).init();
|
|
408
|
+
let stopAccessWatch = () => {};
|
|
409
|
+
const stores = new Map(), projectionQueues = new Map(), storeViews = new WeakMap(), syncCoordinators = new Map(), attachmentStores = new WeakMap();
|
|
410
|
+
let mainStore, mainSource = null;
|
|
411
|
+
const attachmentsFor = store => {
|
|
412
|
+
let attachments = attachmentStores.get(store);
|
|
413
|
+
if (!attachments) {
|
|
414
|
+
attachments = new Attachments(store.root);
|
|
415
|
+
attachmentStores.set(store, attachments);
|
|
416
|
+
}
|
|
417
|
+
return attachments;
|
|
418
|
+
};
|
|
419
|
+
const sourceFile = path.join(project.sharedDir, 'main-source.json');
|
|
420
|
+
let server, base;
|
|
421
|
+
async function updateMainBaseline(source, { force = false } = {}) {
|
|
422
|
+
if (project.kind !== 'git') return source;
|
|
423
|
+
const mainDir = path.join(project.sharedDir, 'main');
|
|
424
|
+
const mainFile = path.join(mainDir, 'map.json');
|
|
425
|
+
await fs.mkdir(mainDir, { recursive: true });
|
|
426
|
+
if (project.bindingRequired) {
|
|
427
|
+
if (!await readJSON(mainFile, null)) await atomicWrite(mainFile, encode({ v: 1, project: path.basename(project.worktreeRoot), bootstrap: 'pending', flows: [], root: null }));
|
|
428
|
+
return { ...source, status: 'binding-required', needsReconcile: true };
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
const { snapshot } = await memoryRequest(project, 'main');
|
|
432
|
+
if (!snapshot) throw new MapError('BASELINE_PENDING', 'No main baseline has been published', 409);
|
|
433
|
+
validate(snapshot.memory.map);
|
|
434
|
+
await atomicWrite(mainFile, encode(snapshot.memory.map));
|
|
435
|
+
return { ...source, status: snapshot.mainSha === source.sha ? 'ready' : 'stale', memoryVersion: snapshot.version, baselineSha: snapshot.mainSha, needsReconcile: snapshot.mainSha !== source.sha };
|
|
436
|
+
} catch (error) {
|
|
437
|
+
// Keep a previously verified baseline; never replace it with an empty map on disconnect.
|
|
438
|
+
if (!await readJSON(mainFile, null)) await atomicWrite(mainFile, encode({ v: 1, project: path.basename(project.worktreeRoot), bootstrap: 'pending', flows: [], root: null }));
|
|
439
|
+
return { ...source, status: error.code || 'memory-unavailable', needsReconcile: true };
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const send = (res, code, data) => { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' }); res.end(JSON.stringify(data)); };
|
|
443
|
+
function broadcast(type, data, viewId = null) {
|
|
444
|
+
for (const peer of peers.values()) if (peer.res && !peer.res.destroyed) {
|
|
445
|
+
if (viewId && peer.viewId !== viewId) continue;
|
|
446
|
+
if (peer.res.writableLength > 2 * 1024 * 1024) { peer.res.destroy(); continue; }
|
|
447
|
+
peer.res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
async function createStore(viewId, storeRoot, options = {}) {
|
|
451
|
+
let target;
|
|
452
|
+
const { syncDirectory, sessionId, ...storeOptions } = options;
|
|
453
|
+
if (sessionId && syncDirectory) await inspectRetiredSync(await resolveProject(storeRoot));
|
|
454
|
+
const projectionRoot = options.projectionRoot || storeRoot;
|
|
455
|
+
const projectMap = project.kind === 'git' && viewId === 'main'
|
|
456
|
+
? async () => true
|
|
457
|
+
: (doc, version) => {
|
|
458
|
+
const previous = projectionQueues.get(viewId) || Promise.resolve();
|
|
459
|
+
const projectionDoc = sessionId ? scopeDocumentToSession(doc, sessionId) : doc;
|
|
460
|
+
const job = previous.then(() => target.version === version ? generateProjections(projectionRoot, projectionDoc, version, () => target.version === version, { sessionId }) : false);
|
|
461
|
+
projectionQueues.set(viewId, job.catch(() => {})); return job;
|
|
462
|
+
};
|
|
463
|
+
// In Cloud mode the local journal is an observation cache, not the Map or
|
|
464
|
+
// the pending write queue. Preserve its raw backup and mark a history gap;
|
|
465
|
+
// never erase a pending commit, operation receipt, or unsent edit.
|
|
466
|
+
const recoverJournal = !!sessionId && !!syncDirectory && !!await readJSON(memoryConfigPath(project), null);
|
|
467
|
+
target = new MapStore(storeRoot, { fault, project: projectMap, ...storeOptions, recoverJournal });
|
|
468
|
+
await target.init();
|
|
469
|
+
storeViews.set(target, new Set([viewId]));
|
|
470
|
+
target.on('change', state => {
|
|
471
|
+
for (const targetView of storeViews.get(target) || []) broadcast('state', { ...state, viewId: targetView, source: sourceFor(targetView) }, targetView);
|
|
472
|
+
});
|
|
473
|
+
stores.set(viewId, target);
|
|
474
|
+
if (sessionId && syncDirectory) {
|
|
475
|
+
const sessionProject = await resolveProject(storeRoot);
|
|
476
|
+
const connection = await projectDevice();
|
|
477
|
+
const coordinator = new MemorySyncCoordinator({ project: sessionProject, sessionId, store: target, directory: syncDirectory,
|
|
478
|
+
managed: !!connection && await connection.supports('private-map-heads'),
|
|
479
|
+
display: async () => {
|
|
480
|
+
const identity = (await access.sessionRegistry()).find(item => item.id === sessionId);
|
|
481
|
+
return identity?.name ? { name: identity.name, platform: identity.platform } : null;
|
|
482
|
+
} });
|
|
483
|
+
coordinator.on('change', status => broadcast('cloud-sync', status, viewId));
|
|
484
|
+
syncCoordinators.set(viewId, coordinator);
|
|
485
|
+
await coordinator.start();
|
|
486
|
+
}
|
|
487
|
+
return target;
|
|
488
|
+
}
|
|
489
|
+
function viewFor(actor, url) {
|
|
490
|
+
if (actor.kind === 'agent') return project.kind === 'git' ? `session:${actor.sessionId}` : 'main';
|
|
491
|
+
const requested = String(url.searchParams.get('view') || 'main');
|
|
492
|
+
if (requested === 'main') return requested;
|
|
493
|
+
if (!requested.startsWith('session:') || !access.binding(requested.slice('session:'.length))) throw new MapError('UNKNOWN_VIEW', 'Select a registered Session view', 404);
|
|
494
|
+
return requested;
|
|
495
|
+
}
|
|
496
|
+
async function ensureSessionMap(sessionProject, sessionId) {
|
|
497
|
+
const dir = sessionMemoryDir(sessionProject, sessionId), file = path.join(dir, 'map.json');
|
|
498
|
+
const baseFile = path.join(dir, 'base-main.json');
|
|
499
|
+
const [existing, baseline] = await Promise.all([readJSON(file, null), readJSON(baseFile, null)]);
|
|
500
|
+
let main = null, remoteSession = null;
|
|
501
|
+
try {
|
|
502
|
+
[main, remoteSession] = await Promise.all([
|
|
503
|
+
memoryRequest(project, 'main').then(result => result.snapshot),
|
|
504
|
+
memoryRequest(sessionProject, `sessions/${encodeURIComponent(sessionId)}`).then(result => result.snapshot),
|
|
505
|
+
]);
|
|
506
|
+
}
|
|
507
|
+
catch (error) { if (error.code !== 'MEMORY_NOT_CONFIGURED') throw error; }
|
|
508
|
+
if (!existing) {
|
|
509
|
+
const seed = remoteSession?.memory?.map || main?.memory?.map || await readJSON(path.join(sessionProject.worktreeRoot, '.codex/context/map.json'));
|
|
510
|
+
if (main) await atomicWrite(baseFile, encode({ version: main.version, map: main.memory.map }));
|
|
511
|
+
await atomicWrite(file, encode(seed));
|
|
512
|
+
} else if (main && !baseline?.map) {
|
|
513
|
+
const legacySeed = await readJSON(path.join(sessionProject.worktreeRoot, '.codex/context/map.json'), null);
|
|
514
|
+
if (hash(encode(existing)) === hash(encode(main.memory.map))) {
|
|
515
|
+
await atomicWrite(baseFile, encode({ version: main.version, map: main.memory.map }));
|
|
516
|
+
} else if (legacySeed && hash(encode(existing)) === hash(encode(legacySeed))) {
|
|
517
|
+
await atomicWrite(path.join(dir, 'before-main-baseline.json'), encode(existing));
|
|
518
|
+
await atomicWrite(file, encode(main.memory.map));
|
|
519
|
+
await atomicWrite(baseFile, encode({ version: main.version, map: main.memory.map }));
|
|
520
|
+
} else {
|
|
521
|
+
throw new MapError('SESSION_BASELINE_REQUIRED', 'Session cache predates the confirmed main baseline and contains changes; preserve it and reconcile explicitly', 409);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return { dir, file };
|
|
525
|
+
}
|
|
526
|
+
async function storeFor(viewId) {
|
|
527
|
+
if (viewId === 'main') return stores.get(viewId);
|
|
528
|
+
const sessionId = viewId.startsWith('session:') ? viewId.slice('session:'.length) : '';
|
|
529
|
+
const binding = access.binding(sessionId);
|
|
530
|
+
if (!binding?.worktreeRoot) throw new MapError('UNKNOWN_SESSION', 'Session is not bound to a worktree', 404);
|
|
531
|
+
const sessionProject = await resolveProject(binding.worktreeRoot);
|
|
532
|
+
if (sessionProject.projectId !== project.projectId) throw new MapError('PROJECT_MISMATCH', 'Session worktree belongs to another project', 403);
|
|
533
|
+
if (stores.has(viewId)) return stores.get(viewId);
|
|
534
|
+
const { dir, file } = await ensureSessionMap(sessionProject, sessionId);
|
|
535
|
+
return createStore(viewId, sessionProject.worktreeRoot, { file, runtime: path.join(dir, 'sync'), eventsFile: path.join(dir, 'changes.jsonl'), projectionRoot: dir, sessionId, syncDirectory: dir });
|
|
536
|
+
}
|
|
537
|
+
function sourceFor(viewId) {
|
|
538
|
+
if (viewId === 'main') return mainSource;
|
|
539
|
+
const binding = access.binding(viewId.slice('session:'.length));
|
|
540
|
+
return binding ? { status: 'worktree', branch: binding.branch || '', sha: binding.head || '', worktreeRoot: binding.worktreeRoot, baseMainSha: binding.baseMainSha || '' } : null;
|
|
541
|
+
}
|
|
542
|
+
const stateFor = (viewId, target, full = true) => ({ ...target.state(full), viewId, source: sourceFor(viewId) });
|
|
543
|
+
let refreshing = null;
|
|
544
|
+
function refreshProject() {
|
|
545
|
+
if (refreshing) return refreshing;
|
|
546
|
+
refreshing = (async () => {
|
|
547
|
+
project = await resolveProject(root);
|
|
548
|
+
const refreshed = await refreshMain(project);
|
|
549
|
+
mainSource = await updateMainBaseline({ ...mainSource, ...refreshed });
|
|
550
|
+
await mainStore.serial(() => mainStore.refresh());
|
|
551
|
+
await atomicWrite(sourceFile, encode(mainSource));
|
|
552
|
+
broadcast('state', stateFor('main', mainStore, false), 'main');
|
|
553
|
+
return { projectId: project.projectId, source: mainSource };
|
|
554
|
+
})().finally(() => { refreshing = null; });
|
|
555
|
+
return refreshing;
|
|
556
|
+
}
|
|
557
|
+
async function cloudSyncStatus(viewId = 'main') {
|
|
558
|
+
if (syncCoordinators.has(viewId)) return syncCoordinators.get(viewId).snapshot();
|
|
559
|
+
return { configured: false, status: 'disabled', cursor: 0, receivedCursor: 0, conflict: null, serviceAlive: false };
|
|
560
|
+
}
|
|
561
|
+
function viewPeers(viewId) { return [...peers.values()].filter(peer => !viewId || peer.viewId === viewId); }
|
|
562
|
+
function pendingPeers(viewId) { return viewPeers(viewId).filter(p => p.dirty || !p.res || p.res.destroyed).map(p => p.id); }
|
|
563
|
+
function evictPeer(peer) {
|
|
564
|
+
if (!peer) return;
|
|
565
|
+
peer.res?.end();
|
|
566
|
+
peers.delete(`${peer.viewId}:${peer.id}`);
|
|
567
|
+
if (!peers.size) openClaimed = false;
|
|
568
|
+
}
|
|
569
|
+
async function registerSession(input, { connection } = {}) {
|
|
570
|
+
const prepared = await preparedSessionBinding(input);
|
|
571
|
+
if (prepared.previous && !prepared.sameWorktree) {
|
|
572
|
+
const view = `session:${prepared.sessionId}`;
|
|
573
|
+
await fence(view);
|
|
574
|
+
for (const [credential, actor] of agentTokens) if (actor.sessionId === prepared.sessionId) agentTokens.delete(credential);
|
|
575
|
+
if (syncCoordinators.has(view)) { await syncCoordinators.get(view).close(); syncCoordinators.delete(view); }
|
|
576
|
+
if (stores.has(view)) { await stores.get(view).close(); stores.delete(view); }
|
|
577
|
+
for (const peer of viewPeers(view)) peer.res?.end();
|
|
578
|
+
}
|
|
579
|
+
if (!(await access.sessionExists(prepared.sessionId, prepared.sessionProject.worktreeRoot))) throw new MapError('UNKNOWN_SESSION', 'Session must exist in the local host before registration', 403);
|
|
580
|
+
const identity = { repositoryId: project.projectId, deviceId: project.projectId, agentId: prepared.sessionId, role: 'executor' };
|
|
581
|
+
const previousProtocolBinding = await protocolStore.registeredBinding(backendPrincipal, prepared.sessionId);
|
|
582
|
+
const bindMessage = { v: 2, id: randomUUID(), type: 'session.bind', payload: { sessionId: prepared.sessionId, agentId: prepared.sessionId,
|
|
583
|
+
worktreeId: prepared.binding.worktreeId, expectedBindingVersion: previousProtocolBinding?.version || '' } };
|
|
584
|
+
const unchanged = previousProtocolBinding?.worktreeId === prepared.binding.worktreeId && previousProtocolBinding?.agentId === prepared.sessionId;
|
|
585
|
+
const protocolBinding = unchanged ? { session: { id: prepared.sessionId, generation: previousProtocolBinding.generation }, bindingVersion: previousProtocolBinding.version } : (await protocolStore.handle(identity, bindMessage, {
|
|
586
|
+
allowMigration: true, verifyBinding: (_p, payload) => payload.sessionId === prepared.sessionId && payload.worktreeId === prepared.binding.worktreeId,
|
|
587
|
+
})).data;
|
|
588
|
+
if (connection === undefined) connection = await projectDevice();
|
|
589
|
+
let cloudBinding = { status: 'disconnected' };
|
|
590
|
+
if (connection && await connection.connected()) {
|
|
591
|
+
try {
|
|
592
|
+
if (unchanged) await connection.ensureBinding(previousProtocolBinding);
|
|
593
|
+
else await connection.bind(bindMessage, protocolBinding);
|
|
594
|
+
cloudBinding = { status: 'ready' };
|
|
595
|
+
}
|
|
596
|
+
catch (error) { cloudBinding = { status: 'pending', code: error.code || 'UNAVAILABLE' }; }
|
|
597
|
+
}
|
|
598
|
+
const lazyCloudMap = connection && await connection.supports('private-map-heads');
|
|
599
|
+
const sessionFiles = project.kind === 'git' && !lazyCloudMap && !stores.has(`session:${prepared.sessionId}`) ? await ensureSessionMap(prepared.sessionProject, prepared.sessionId) : null;
|
|
600
|
+
const actor = await access.register(prepared.sessionId, prepared.binding);
|
|
601
|
+
if (sessionFiles) {
|
|
602
|
+
const sessionDocument = await readJSON(sessionFiles.file, null);
|
|
603
|
+
await access.promoteLegacyFullGrant(prepared.sessionId, sessionDocument);
|
|
604
|
+
}
|
|
605
|
+
if (sessionFiles && !stores.has(`session:${prepared.sessionId}`)) {
|
|
606
|
+
await createStore(`session:${prepared.sessionId}`, prepared.sessionProject.worktreeRoot, {
|
|
607
|
+
file: sessionFiles.file,
|
|
608
|
+
runtime: path.join(sessionFiles.dir, 'sync'),
|
|
609
|
+
eventsFile: path.join(sessionFiles.dir, 'changes.jsonl'),
|
|
610
|
+
projectionRoot: sessionFiles.dir,
|
|
611
|
+
sessionId: prepared.sessionId,
|
|
612
|
+
syncDirectory: sessionFiles.dir,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
const credential = token(); agentTokens.set(credential, actor);
|
|
616
|
+
return { token: credential, actor, protocolBinding, cloudBinding };
|
|
617
|
+
}
|
|
618
|
+
const checkpoints = new Map();
|
|
619
|
+
const fenceDeadlineMs = Number(process.env.CONTEXT_GUARD_FENCE_MS) > 0 ? Number(process.env.CONTEXT_GUARD_FENCE_MS) : 8000;
|
|
620
|
+
async function fence(viewId, { stopping = false } = {}) {
|
|
621
|
+
const checkpoint = randomUUID();
|
|
622
|
+
const pending = new Set(viewPeers(viewId));
|
|
623
|
+
checkpoints.set(checkpoint, pending);
|
|
624
|
+
try {
|
|
625
|
+
broadcast('checkpoint', { checkpoint }, viewId);
|
|
626
|
+
// Allow a background browser to resume and flush through the named proxy.
|
|
627
|
+
// Remain below the client's 10-second request timeout and fail closed.
|
|
628
|
+
const deadline = Date.now() + (stopping ? Math.min(fenceDeadlineMs, 2000) : fenceDeadlineMs);
|
|
629
|
+
while (pending.size) {
|
|
630
|
+
const live = new Set(viewPeers(viewId));
|
|
631
|
+
for (const peer of pending) if (!live.has(peer) || !peer.res || peer.res.destroyed) pending.delete(peer);
|
|
632
|
+
if (!pending.size) break;
|
|
633
|
+
if (Date.now() >= deadline) {
|
|
634
|
+
// A hung SSE connection is not a live page. Evict it like a closed tab
|
|
635
|
+
// so Agent reads and CLI stop are not blocked indefinitely.
|
|
636
|
+
for (const peer of pending) evictPeer(peer);
|
|
637
|
+
pending.clear();
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
await pause(15);
|
|
641
|
+
}
|
|
642
|
+
if (!stopping && pendingPeers(viewId).length) throw new MapError('UI_PENDING', 'A page has unsaved edits', 409, { peers: pendingPeers(viewId) });
|
|
643
|
+
} finally {
|
|
644
|
+
checkpoints.delete(checkpoint);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
async function body(req) {
|
|
648
|
+
if (!String(req.headers['content-type'] || '').startsWith('application/json')) throw new MapError('CONTENT_TYPE', 'Use application/json', 415);
|
|
649
|
+
let size = 0, chunks = [];
|
|
650
|
+
for await (const chunk of req) { size += chunk.length; if (size > 16 * 1024 * 1024) throw new MapError('BODY_TOO_LARGE', 'Request exceeds 16 MiB', 413); chunks.push(chunk); }
|
|
651
|
+
try { return JSON.parse(Buffer.concat(chunks)); } catch { throw new MapError('INVALID_JSON', 'Malformed JSON'); }
|
|
652
|
+
}
|
|
653
|
+
function auth(req, url) {
|
|
654
|
+
const credential = req.headers.authorization?.replace(/^Bearer /, '') || (url.pathname === '/api/events' ? url.searchParams.get('token') : null);
|
|
655
|
+
if (credential === humanToken) return { kind: 'human', sessionId: 'workbench' };
|
|
656
|
+
const actor = agentTokens.get(credential);
|
|
657
|
+
if (!actor) throw new MapError('UNAUTHORIZED', 'Missing or expired capability', 401);
|
|
658
|
+
return actor;
|
|
659
|
+
}
|
|
660
|
+
async function preparedSessionBinding(input) {
|
|
661
|
+
const sessionId = typeof input?.sessionId === 'string' ? input.sessionId.trim() : '';
|
|
662
|
+
if (!sessionId) throw new MapError('SESSION_REQUIRED', 'A real lifecycle Session ID is required', 400);
|
|
663
|
+
const sessionProject = await resolveProject(input.worktreeRoot || root);
|
|
664
|
+
if (sessionProject.projectId !== project.projectId) throw new MapError('PROJECT_MISMATCH', 'Session worktree belongs to another project', 403);
|
|
665
|
+
const previous = access.binding(sessionId);
|
|
666
|
+
const role = input.role === undefined ? previous?.role || 'executor' : input.role;
|
|
667
|
+
if (!['executor', 'coordinator'].includes(role)) throw new MapError('INVALID_ROLE', 'Use executor or coordinator for Session context', 400);
|
|
668
|
+
const sameWorktree = previous && (previous.worktreeId === sessionProject.worktreeId
|
|
669
|
+
|| !previous.gitDir && previous.worktreeRoot === sessionProject.worktreeRoot);
|
|
670
|
+
if (previous && !sameWorktree) {
|
|
671
|
+
if (!input.allowRebind) throw new MapError('SESSION_ALREADY_BOUND', 'Session is already bound to another worktree; require explicit user confirmation and --rebind', 409, {
|
|
672
|
+
worktreeRoot: previous.worktreeRoot,
|
|
673
|
+
bindingState: await fs.realpath(previous.worktreeRoot || '').then(() => 'other-worktree').catch(() => 'stale'),
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
let workbenchUrl = previous?.workbenchUrl || null;
|
|
677
|
+
if (input.workbenchUrl) {
|
|
678
|
+
const candidate = new URL(String(input.workbenchUrl));
|
|
679
|
+
candidate.search = ''; candidate.hash = '';
|
|
680
|
+
const allowedOrigin = candidate.origin === base || candidate.origin === namedEntry?.origin;
|
|
681
|
+
if (!allowedOrigin || candidate.pathname !== '/prototype/workbench.html') throw new MapError('INVALID_WORKBENCH_URL', 'Binding URL must be this project workbench page', 400);
|
|
682
|
+
workbenchUrl = candidate.href;
|
|
683
|
+
}
|
|
684
|
+
return { sessionId, sessionProject, previous, sameWorktree, binding: await sessionBinding(sessionProject, sessionId, { workbenchUrl, role }) };
|
|
685
|
+
}
|
|
686
|
+
const isHuman = actor => { if (actor.kind !== 'human') throw new MapError('FORBIDDEN', 'Requires the workbench capability', 403); };
|
|
687
|
+
try {
|
|
688
|
+
const previousSource = await readJSON(sourceFile, null);
|
|
689
|
+
const baselineSha = previousSource?.baselineSha || previousSource?.sha || project.mainSha || '';
|
|
690
|
+
mainSource = {
|
|
691
|
+
status: project.bindingRequired ? 'binding-required' : project.kind === 'git' ? 'ready' : 'local-folder',
|
|
692
|
+
branch: project.mainBranch || '', ref: project.mainRef || '', sha: project.mainSha || '', github: project.github,
|
|
693
|
+
baselineSha,
|
|
694
|
+
checkedAt: new Date().toISOString(),
|
|
695
|
+
needsReconcile: !!(baselineSha && project.mainSha && baselineSha !== project.mainSha),
|
|
696
|
+
};
|
|
697
|
+
if (project.kind === 'git') {
|
|
698
|
+
const mainDir = path.join(project.sharedDir, 'main');
|
|
699
|
+
const mainFile = path.join(mainDir, 'map.json');
|
|
700
|
+
mainSource = await updateMainBaseline(mainSource, { force: true });
|
|
701
|
+
await atomicWrite(sourceFile, encode(mainSource));
|
|
702
|
+
mainStore = await createStore('main', project.worktreeRoot, {
|
|
703
|
+
file: mainFile,
|
|
704
|
+
runtime: path.join(mainDir, 'sync'),
|
|
705
|
+
eventsFile: path.join(mainDir, 'workbench-changes.jsonl'),
|
|
706
|
+
});
|
|
707
|
+
} else {
|
|
708
|
+
await atomicWrite(sourceFile, encode(mainSource));
|
|
709
|
+
mainStore = await createStore('main', root);
|
|
710
|
+
}
|
|
711
|
+
server = http.createServer(async (req, res) => {
|
|
712
|
+
try {
|
|
713
|
+
const direct = req.headers.host === new URL(base).host;
|
|
714
|
+
const named = namedEntry && req.headers.host === new URL(namedEntry.origin).host && req.headers['x-context-guard-proxy'] === namedEntry.proxyToken;
|
|
715
|
+
if (!direct && !named) throw new MapError('HOST_REJECTED', 'Invalid Host', 403);
|
|
716
|
+
const requestOrigin = direct ? base : namedEntry.origin;
|
|
717
|
+
if (req.headers.origin && req.headers.origin !== requestOrigin) throw new MapError('ORIGIN_REJECTED', 'Cross-origin requests are not allowed', 403);
|
|
718
|
+
const url = new URL(req.url, base), route = url.pathname;
|
|
719
|
+
if (route === '/api/claude-runtime' && req.method === 'POST') {
|
|
720
|
+
if (!direct || req.headers.origin || req.headers.authorization !== `Bearer ${adminToken}`) throw new MapError('UNAUTHORIZED', 'Requires local CLI credential', 401);
|
|
721
|
+
const input = await body(req), binding = access.binding(input.sessionId);
|
|
722
|
+
if (input.recovery) {
|
|
723
|
+
if (input.config || !binding) throw new MapError('WORKTREE_MISMATCH', 'Recover only a registered Claude worktree', 409);
|
|
724
|
+
return send(res, 200, await claudeRuntime.recover(input.sessionId, input.recovery, binding.worktreeRoot));
|
|
725
|
+
}
|
|
726
|
+
if (!binding || await fs.realpath(input.config?.root || '') !== binding.worktreeRoot) throw new MapError('WORKTREE_MISMATCH', 'Configure only the bound Claude worktree', 409);
|
|
727
|
+
if (input.config.role === 'ci') {
|
|
728
|
+
const executor = access.binding(input.config.executorSessionId);
|
|
729
|
+
if (!executor || executor.worktreeRoot === binding.worktreeRoot) protocolFail('FORBIDDEN', 'CI needs an independent registered worktree');
|
|
730
|
+
}
|
|
731
|
+
return send(res, 200, await claudeRuntime.configure(input.sessionId, input.config));
|
|
732
|
+
}
|
|
733
|
+
if (route === '/api/device-heartbeat') {
|
|
734
|
+
if (!direct || req.headers.origin || req.headers.authorization !== `Bearer ${adminToken}`) throw new MapError('UNAUTHORIZED', 'Requires device service credential', 401);
|
|
735
|
+
const connected = await projectDevice();
|
|
736
|
+
if (!connected?.runtime?.prepare) throw new MapError('UNAVAILABLE', 'Cloud connection unavailable', 503);
|
|
737
|
+
if (req.method === 'GET') return send(res, 200, { ...await connected.runtime.prepare(), lastError: connected.lastError || null });
|
|
738
|
+
if (req.method === 'POST') { connected.runtime.accept(await body(req)); return send(res, 200, { accepted: true }); }
|
|
739
|
+
throw new MapError('INVALID_ARGUMENT', 'Use GET or POST', 400);
|
|
740
|
+
}
|
|
741
|
+
if (route === '/__context_guard/health' && req.method === 'GET') return send(res, 200, { ok: true, ...runtimeIdentity(), root, projectId: project.projectId, worktreeRoot: project.worktreeRoot, worktreeId: project.worktreeId, pid: process.pid, instance, namedEntry: true, namedRoot: project.kind === 'git' ? project.sharedDir : root, recovery: mainStore.blocked, rss: process.memoryUsage().rss });
|
|
742
|
+
if (route === '/__context_guard/bootstrap' && req.method === 'GET') return send(res, 200, { token: humanToken, root: `project:${project.projectId}`, projectId: project.projectId, bindingRequired: project.bindingRequired, instance, interfaceCapabilities: { deviceLogin: true }, ...runtimeIdentity() });
|
|
743
|
+
if (route === '/api/v2/coordinator-tools' && req.method === 'POST') {
|
|
744
|
+
const actor = auth(req, url);
|
|
745
|
+
if (actor.kind !== 'agent') throw new MapError('FORBIDDEN', 'Coordinator tools require a coordinator Session', 403);
|
|
746
|
+
if (access.binding(actor.sessionId)?.role !== 'coordinator') throw new MapError('FORBIDDEN', 'This Session is not a Coordinator', 403);
|
|
747
|
+
const connection = await projectDevice();
|
|
748
|
+
if (!connection || !await connection.connected()) throw new MapError('UNAVAILABLE', 'Cloud connection unavailable', 503);
|
|
749
|
+
try {
|
|
750
|
+
return send(res, 200, await connection.callCoordinatorTool(await body(req)));
|
|
751
|
+
} catch (error) {
|
|
752
|
+
throw new MapError(error.code || 'UNAVAILABLE', error.message || 'Coordinator tool failed', error.status || 503);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
if (route === '/api/v2/messages') return messageHandler({
|
|
756
|
+
allowedOrigin: requestOrigin,
|
|
757
|
+
authenticate: request => {
|
|
758
|
+
let actor;
|
|
759
|
+
try { actor = auth(request, url); } catch { return null; }
|
|
760
|
+
return { repositoryId: project.projectId, deviceId: project.projectId, agentId: actor.sessionId, actor, role: actor.kind === 'human' ? 'human' : 'executor' };
|
|
761
|
+
},
|
|
762
|
+
handle: async (principal, input) => {
|
|
763
|
+
const connection = await projectDevice();
|
|
764
|
+
if (input.type.startsWith('auth.')) {
|
|
765
|
+
if (principal.role !== 'human') protocolFail('FORBIDDEN', 'Only the local human workbench can authorize the backend');
|
|
766
|
+
if (!connection) protocolFail('INVALID_ARGUMENT', 'Configure the project Cloud origin first');
|
|
767
|
+
let data;
|
|
768
|
+
if (input.type === 'auth.open') {
|
|
769
|
+
const current = await resolveProject(root);
|
|
770
|
+
if (!current.github || current.bindingRequired) protocolFail('INVALID_ARGUMENT', 'A verified GitHub Main remote is required');
|
|
771
|
+
const identity = await repositoryLookup(current.github.slug);
|
|
772
|
+
data = await connection.connect({ ...input, payload: { ...input.payload, repository: `https://github.com/${current.github.slug}` } }, identity);
|
|
773
|
+
await projectDevice();
|
|
774
|
+
} else data = await connection.disconnect(input);
|
|
775
|
+
return { id: input.id, ok: true, data };
|
|
776
|
+
}
|
|
777
|
+
const human = principal.role === 'human';
|
|
778
|
+
if (human && !['workbench.read', 'workbench.patch'].includes(input.type)) protocolFail('FORBIDDEN', 'This message requires an Agent Session');
|
|
779
|
+
const connected = connection && await connection.connected();
|
|
780
|
+
if (input.type === 'workbench.patch') {
|
|
781
|
+
await protocolStore.authorizeSession(principal, input.session);
|
|
782
|
+
const sessionId = input.session.id;
|
|
783
|
+
const viewId = project.kind === 'git' ? `session:${sessionId}` : 'main';
|
|
784
|
+
const target = await storeFor(viewId);
|
|
785
|
+
const actor = human ? { ...principal.actor, sessionId } : principal.actor;
|
|
786
|
+
const data = await protocolMap.patch(principal, input, { store: target, actor,
|
|
787
|
+
references: () => verifyChangeReferences(input.payload.changes, {
|
|
788
|
+
object: async (ref, version) => {
|
|
789
|
+
const read = { v: 2, id: randomUUID(), type: 'object.read', session: input.session, payload: { ref, version } };
|
|
790
|
+
return connected ? connection.send(read) : (await protocolStore.handle(principal, read)).data;
|
|
791
|
+
},
|
|
792
|
+
blob: blobId => connected ? connection.send({ v: 2, id: randomUUID(), type: 'blob.get', session: input.session, payload: { blobId } }) : protocolBlobs.metadata(principal, input.session, blobId),
|
|
793
|
+
}),
|
|
794
|
+
grants: () => human ? [...entries(target.doc.root).keys()] : access.grants(principal.agentId, target.doc),
|
|
795
|
+
prepare: request => prepareSessionCommit(target, request, actor, sessionId),
|
|
796
|
+
authorize: async () => {
|
|
797
|
+
const binding = access.binding(sessionId);
|
|
798
|
+
if (!binding || !human && binding.worktreeId !== principal.actor.worktreeId) protocolFail('FORBIDDEN', 'Agent binding changed');
|
|
799
|
+
await protocolStore.authorizeSession(principal, input.session);
|
|
800
|
+
if (pendingPeers(viewId).length) protocolFail('CONFLICT', 'Page edits are pending');
|
|
801
|
+
},
|
|
802
|
+
});
|
|
803
|
+
return { id: input.id, ok: true, data };
|
|
804
|
+
}
|
|
805
|
+
if (input.type === 'workbench.read') {
|
|
806
|
+
await protocolStore.authorizeSession(principal, input.session);
|
|
807
|
+
const sessionId = input.session.id, binding = access.binding(sessionId);
|
|
808
|
+
if (!binding || !human && binding.worktreeId !== principal.actor.worktreeId) protocolFail('FORBIDDEN', 'Agent binding is no longer active');
|
|
809
|
+
const target = await storeFor(project.kind === 'git' ? `session:${sessionId}` : 'main');
|
|
810
|
+
let source;
|
|
811
|
+
const load = async () => {
|
|
812
|
+
if (source) return source;
|
|
813
|
+
if (input.payload.scope === 'main') {
|
|
814
|
+
let snapshot;
|
|
815
|
+
try { ({ snapshot } = await memoryRequest(project, 'main')); }
|
|
816
|
+
catch (error) { protocolFail(error.code === 'MEMORY_NOT_CONFIGURED' ? 'NOT_FOUND' : 'UNAVAILABLE', 'A confirmed Main snapshot is unavailable'); }
|
|
817
|
+
if (!snapshot?.memory?.map) protocolFail('NOT_FOUND', 'Published Main snapshot is unavailable');
|
|
818
|
+
return source = { doc: scopeDocumentToSession(snapshot.memory.map, sessionId), version: snapshot.version };
|
|
819
|
+
}
|
|
820
|
+
await target.serial(() => target.refresh());
|
|
821
|
+
if (target.error || target.blocked) protocolFail('UNAVAILABLE', 'Session Map requires recovery');
|
|
822
|
+
return source = { doc: scopeDocumentToSession(target.doc, sessionId), version: target.version };
|
|
823
|
+
};
|
|
824
|
+
const data = await protocolSnapshots.read(principal, input, {
|
|
825
|
+
load,
|
|
826
|
+
capture: () => protocolStore.recoverySnapshot(principal, input.session, load),
|
|
827
|
+
grants: async () => {
|
|
828
|
+
const doc = (await load()).doc;
|
|
829
|
+
return human ? [...entries(doc.root).keys()] : access.grants(principal.agentId, doc, 'read');
|
|
830
|
+
},
|
|
831
|
+
});
|
|
832
|
+
return { id: input.id, ok: true, data };
|
|
833
|
+
}
|
|
834
|
+
if (connected && (/^(object\.(put|read)|blob\.(put|get))$/.test(input.type) || workflowTypes.has(input.type))) {
|
|
835
|
+
const binding = access.binding(principal.actor.sessionId);
|
|
836
|
+
if (!binding || binding.worktreeId !== principal.actor.worktreeId) protocolFail('FORBIDDEN', 'Agent binding is no longer active');
|
|
837
|
+
if (input.session) await protocolStore.authorizeSession(principal, input.session);
|
|
838
|
+
for (const session of input.payload.sessions || []) await protocolStore.authorizeSession(principal, session);
|
|
839
|
+
return { id: input.id, ok: true, data: await connection.send(input) };
|
|
840
|
+
}
|
|
841
|
+
const reply = await protocolStore.handle(principal, input, {
|
|
842
|
+
blobs: protocolBlobs,
|
|
843
|
+
verifyBinding: (identity, payload) => {
|
|
844
|
+
const binding = access.binding(identity.agentId);
|
|
845
|
+
return payload.sessionId === identity.agentId && binding && payload.worktreeId === binding.worktreeId;
|
|
846
|
+
},
|
|
847
|
+
authorize: () => {
|
|
848
|
+
const binding = access.binding(principal.actor.sessionId);
|
|
849
|
+
if (!binding || binding.worktreeId !== principal.actor.worktreeId) protocolFail('FORBIDDEN', 'Agent binding is no longer active');
|
|
850
|
+
},
|
|
851
|
+
});
|
|
852
|
+
if (connected && input.type === 'session.bind') {
|
|
853
|
+
await connection.bind(input, reply.data);
|
|
854
|
+
return reply;
|
|
855
|
+
}
|
|
856
|
+
return reply;
|
|
857
|
+
},
|
|
858
|
+
})(req, res);
|
|
859
|
+
if (route === '/api/v2/execution' && req.method === 'GET') {
|
|
860
|
+
const actor = auth(req, url);
|
|
861
|
+
if (actor.kind !== 'agent') protocolFail('FORBIDDEN', 'A registered Agent is required');
|
|
862
|
+
if ((await claudeRuntime.status(actor.sessionId).catch(() => null))?.role === 'ci') {
|
|
863
|
+
try { return send(res, 200, { active: await claudeRuntime.ciContext(actor.sessionId) }); }
|
|
864
|
+
catch (error) { if (error.code === 'CI_NOT_ACTIVE') return send(res, 200, { active: null, ci: true }); throw error; }
|
|
865
|
+
}
|
|
866
|
+
const principal = { repositoryId: project.projectId, deviceId: project.projectId, agentId: actor.sessionId, role: 'executor' };
|
|
867
|
+
const binding = await protocolStore.registeredBinding(principal, actor.sessionId);
|
|
868
|
+
if (!binding || access.binding(actor.sessionId)?.worktreeId !== actor.worktreeId) protocolFail('FORBIDDEN', 'Session binding changed');
|
|
869
|
+
const session = { id: actor.sessionId, generation: binding.generation };
|
|
870
|
+
const active = await protocolStore.activeExecution(principal, session);
|
|
871
|
+
let plan = null;
|
|
872
|
+
if (active?.mode === 'reviewed' && active.approval && active.plan) {
|
|
873
|
+
const connection = await projectDevice();
|
|
874
|
+
if (!connection) protocolFail('UNAVAILABLE', 'Cloud is required to verify the approved Plan');
|
|
875
|
+
plan = await connection.send({ v: 2, id: randomUUID(), type: 'object.read', session, payload: { ref: active.plan.ref, version: active.plan.version } });
|
|
876
|
+
}
|
|
877
|
+
return send(res, 200, { session, active, plan });
|
|
878
|
+
}
|
|
879
|
+
if (route === '/api/v2/ci' && req.method === 'POST') {
|
|
880
|
+
const actor = auth(req, url);
|
|
881
|
+
if (actor.kind !== 'agent' || access.binding(actor.sessionId)?.worktreeId !== actor.worktreeId) protocolFail('FORBIDDEN', 'A current CI Session binding is required');
|
|
882
|
+
const input = await body(req), context = await claudeRuntime.ciContext(actor.sessionId, { verifySource: input.type === 'ci.result' });
|
|
883
|
+
if (!context || input.session && JSON.stringify(input.session) !== JSON.stringify(context.session)) protocolFail('FORBIDDEN', 'CI message targets a different developer Session');
|
|
884
|
+
const message = validateMessage({ ...input, session: context.session });
|
|
885
|
+
if (!['object.read', 'object.put', 'ci.result'].includes(message.type) || message.type === 'object.put' &&
|
|
886
|
+
(message.payload.kind !== 'evidence' || !message.payload.ref.startsWith(`ci:${actor.sessionId}:`)) || message.type === 'ci.result' &&
|
|
887
|
+
(message.payload.taskId !== context.taskId || message.payload.sourceSha !== context.sourceSha)) protocolFail('FORBIDDEN', 'CI message exceeds its assigned test scope');
|
|
888
|
+
const connection = await projectDevice();
|
|
889
|
+
if (!connection) protocolFail('UNAVAILABLE', 'Cloud connection is unavailable');
|
|
890
|
+
return send(res, 200, { id: message.id, ok: true, data: await ciChannel(actor.sessionId, connection).send(message) });
|
|
891
|
+
}
|
|
892
|
+
if (route === '/api/v2/task-report' && req.method === 'POST') {
|
|
893
|
+
const actor = auth(req, url);
|
|
894
|
+
if (actor.kind !== 'agent') protocolFail('FORBIDDEN', 'A registered Agent is required');
|
|
895
|
+
const principal = { repositoryId: project.projectId, deviceId: project.projectId, agentId: actor.sessionId, role: 'executor' };
|
|
896
|
+
const binding = await protocolStore.registeredBinding(principal, actor.sessionId);
|
|
897
|
+
if (!binding || access.binding(actor.sessionId)?.worktreeId !== actor.worktreeId) protocolFail('FORBIDDEN', 'Session binding changed');
|
|
898
|
+
const message = await protocolStore.executionReport(principal, { id: actor.sessionId, generation: binding.generation }, await body(req));
|
|
899
|
+
const connection = await projectDevice();
|
|
900
|
+
if (!connection) protocolFail('UNAVAILABLE', 'Cloud connection is unavailable; retry the same command');
|
|
901
|
+
return send(res, 200, { id: message.id, ok: true, data: await connection.send(message) });
|
|
902
|
+
}
|
|
903
|
+
if (route === '/api/v2/interrupt' && req.method === 'POST') {
|
|
904
|
+
const actor = auth(req, url);
|
|
905
|
+
if (actor.kind !== 'agent') protocolFail('FORBIDDEN', 'A registered Agent is required');
|
|
906
|
+
const principal = { repositoryId: project.projectId, deviceId: project.projectId, agentId: actor.sessionId, role: 'executor' };
|
|
907
|
+
const binding = await protocolStore.registeredBinding(principal, actor.sessionId);
|
|
908
|
+
if (!binding || access.binding(actor.sessionId)?.worktreeId !== actor.worktreeId) protocolFail('FORBIDDEN', 'Session binding changed');
|
|
909
|
+
const session = { id: actor.sessionId, generation: binding.generation };
|
|
910
|
+
const input = await body(req), active = await protocolStore.activeExecution(principal, session);
|
|
911
|
+
let message = validateMessage({ v: 2, id: input.id, type: 'task.report', session,
|
|
912
|
+
payload: { taskId: active?.taskId || 'no-cloud-task', stage: 'interrupted', data: { reason: input.reason, occurredAt: input.occurredAt } } });
|
|
913
|
+
const connection = await projectDevice();
|
|
914
|
+
const prior = await connection?.savedMessage(input.id);
|
|
915
|
+
if (prior) {
|
|
916
|
+
if (prior.session?.id !== session.id || prior.session.generation !== session.generation || prior.type !== 'task.report' || prior.payload.stage !== 'interrupted' || prior.payload.data.occurredAt !== input.occurredAt || prior.payload.data.reason !== input.reason) protocolFail('ID_REUSED', 'Interruption ID was already used');
|
|
917
|
+
message = prior;
|
|
918
|
+
} else if (!active || Date.parse(input.occurredAt) < Date.parse(active.assignedAt)) return send(res, 200, { queued: false, reason: 'No matching active Cloud task' });
|
|
919
|
+
if (!connection) protocolFail('UNAVAILABLE', 'Cloud connection is not configured; keep the hook event for retry');
|
|
920
|
+
const data = await connection.send(message);
|
|
921
|
+
return send(res, 200, { queued: true, synchronized: true, taskId: message.payload.taskId, receipt: data });
|
|
922
|
+
}
|
|
923
|
+
const binaryRoute = route.match(/^\/api\/v2\/blobs\/([a-f0-9]{64})$/);
|
|
924
|
+
if (binaryRoute) {
|
|
925
|
+
const actor = auth(req, url);
|
|
926
|
+
if (actor.kind !== 'agent') protocolFail('FORBIDDEN', 'A registered Agent credential is required');
|
|
927
|
+
const principal = { repositoryId: project.projectId, deviceId: project.projectId, agentId: actor.sessionId };
|
|
928
|
+
const session = { id: req.headers['x-context-guard-session'], generation: Number(req.headers['x-context-guard-generation']) };
|
|
929
|
+
await protocolStore.authorizeSession(principal, session);
|
|
930
|
+
const binding = access.binding(actor.sessionId);
|
|
931
|
+
if (!binding || binding.worktreeId !== actor.worktreeId) protocolFail('FORBIDDEN', 'Agent binding is no longer active');
|
|
932
|
+
const connection = await projectDevice();
|
|
933
|
+
if (connection && await connection.connected()) return await connection.proxyBlob(req, res, session, binaryRoute[1]);
|
|
934
|
+
return await serveBlob(req, res, { blobs: protocolBlobs, principal, session, blobId: binaryRoute[1] });
|
|
935
|
+
}
|
|
936
|
+
if (['/api/named-entry', '/api/open-claim'].includes(route) && req.method === 'POST') {
|
|
937
|
+
if (!direct || req.headers.authorization !== `Bearer ${adminToken}`) throw new MapError('UNAUTHORIZED', 'Requires local CLI credential', 401);
|
|
938
|
+
const input = await body(req);
|
|
939
|
+
if (route === '/api/open-claim') {
|
|
940
|
+
const shouldOpen = !peers.size && (!openClaimed || input.retry === true);
|
|
941
|
+
if (shouldOpen) openClaimed = true;
|
|
942
|
+
return send(res, 200, { shouldOpen });
|
|
943
|
+
}
|
|
944
|
+
if (!validNamedOrigin(input.origin) || typeof input.proxyToken !== 'string' || input.proxyToken.length < 32) throw new MapError('INVALID_ORIGIN', 'Expected an exact local project HTTP origin');
|
|
945
|
+
namedEntry = { name: new URL(input.origin).hostname.slice(0, -10), origin: input.origin, proxyToken: namedEntry?.proxyToken || input.proxyToken };
|
|
946
|
+
await atomicWrite(namedFile, encode(namedEntry));
|
|
947
|
+
return send(res, 200, { proxyToken: namedEntry.proxyToken });
|
|
948
|
+
}
|
|
949
|
+
if (['/api/session-prepare', '/api/session'].includes(route) && req.method === 'POST') {
|
|
950
|
+
if (req.headers.authorization !== `Bearer ${adminToken}`) throw new MapError('UNAUTHORIZED', 'Requires local CLI credential', 401);
|
|
951
|
+
const input = await body(req);
|
|
952
|
+
const prepared = await preparedSessionBinding(input);
|
|
953
|
+
if (route === '/api/session-prepare') return send(res, 200, { prepared: true, binding: prepared.binding, previous: prepared.previous || null });
|
|
954
|
+
return send(res, 200, await registerSession(input));
|
|
955
|
+
}
|
|
956
|
+
if (route === '/api/stop' && req.method === 'POST') {
|
|
957
|
+
if (req.headers.authorization !== `Bearer ${adminToken}`) throw new MapError('UNAUTHORIZED', 'Requires local CLI credential', 401);
|
|
958
|
+
for (const viewId of stores.keys()) await fence(viewId, { stopping: true });
|
|
959
|
+
send(res, 202, { stopping: true }); setImmediate(() => close()); return;
|
|
960
|
+
}
|
|
961
|
+
if (route === '/api/project-refresh' && req.method === 'POST') {
|
|
962
|
+
if (req.headers.authorization !== `Bearer ${adminToken}`) throw new MapError('UNAUTHORIZED', 'Requires local CLI credential', 401);
|
|
963
|
+
return send(res, 200, await refreshProject());
|
|
964
|
+
}
|
|
965
|
+
if (route.startsWith('/api/')) {
|
|
966
|
+
const actor = auth(req, url);
|
|
967
|
+
const viewId = viewFor(actor, url);
|
|
968
|
+
const activeStore = await storeFor(viewId);
|
|
969
|
+
if (route === '/api/cloud-sync' && req.method === 'GET') { isHuman(actor); return send(res, 200, await cloudSyncStatus(viewId)); }
|
|
970
|
+
if (route === '/api/events' && req.method === 'GET') {
|
|
971
|
+
isHuman(actor); const id = url.searchParams.get('clientId');
|
|
972
|
+
if (!id || id.length > 100) throw new MapError('INVALID_CLIENT', 'Invalid clientId');
|
|
973
|
+
const peerId = `${viewId}:${id}`;
|
|
974
|
+
let peer = peers.get(peerId) || { id, viewId, dirty: false, version: null };
|
|
975
|
+
peer.res?.end(); peer.res = res; peers.set(peerId, peer);
|
|
976
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' });
|
|
977
|
+
res.write(`retry: 500\nevent: state\ndata: ${JSON.stringify(stateFor(viewId, activeStore, false))}\n\n`);
|
|
978
|
+
req.on('close', () => {
|
|
979
|
+
if (peer.res !== res) return;
|
|
980
|
+
// The synchronization fence only represents live pages. Dirty browser
|
|
981
|
+
// state is already captured by the page recovery layer; retaining a
|
|
982
|
+
// disconnected peer would make a closed tab block every later Agent.
|
|
983
|
+
peers.delete(peerId);
|
|
984
|
+
if (!peers.size) openClaimed = false;
|
|
985
|
+
}); return;
|
|
986
|
+
}
|
|
987
|
+
if (route === '/api/presence' && req.method === 'POST') {
|
|
988
|
+
isHuman(actor); const input = await body(req), peerId = `${viewId}:${input.clientId}`, peer = peers.get(peerId);
|
|
989
|
+
if (peer) { peer.dirty = !!input.dirty; peer.version = input.version; checkpoints.get(input.checkpoint)?.delete(peer); if (input.closing && !peer.dirty) { peer.res?.end(); peers.delete(peerId); } }
|
|
990
|
+
return send(res, 200, { version: activeStore.version, viewId, source: sourceFor(viewId), synchronized: input.version === activeStore.version && !input.dirty && !activeStore.error && !activeStore.blocked, error: activeStore.error, recovery: activeStore.blocked });
|
|
991
|
+
}
|
|
992
|
+
if (route === '/api/state' && req.method === 'GET') {
|
|
993
|
+
if (actor.kind === 'agent') await fence(viewId);
|
|
994
|
+
await activeStore.serial(() => activeStore.refresh());
|
|
995
|
+
const sessionId = actor.kind === 'agent' ? actor.sessionId : viewId.startsWith('session:') ? viewId.slice('session:'.length) : '';
|
|
996
|
+
const state = stateFor(viewId, activeStore);
|
|
997
|
+
if (sessionId && state.doc) state.doc = scopeDocumentToSession(state.doc, sessionId);
|
|
998
|
+
if (url.searchParams.has('node')) { const entry = state.doc && entries(state.doc.root).get(url.searchParams.get('node')); if (!entry) throw new MapError('NOT_FOUND', 'Node missing', 404); delete state.doc; state.node = entry.node; state.parentId = entry.parent?.id || null; }
|
|
999
|
+
return send(res, 200, { ...state, viewId, actor, grants: access.grants(actor.sessionId, activeStore.doc), peers: viewPeers(viewId).map(({ id, dirty, version }) => ({ id, dirty, version })) });
|
|
1000
|
+
}
|
|
1001
|
+
if (route === '/api/changes' && req.method === 'GET') {
|
|
1002
|
+
await activeStore.serial(() => activeStore.refresh());
|
|
1003
|
+
const changes = activeStore.changes(url.searchParams.get('cursor'));
|
|
1004
|
+
const sessionId = actor.kind === 'agent' ? actor.sessionId : viewId.startsWith('session:') ? viewId.slice('session:'.length) : '';
|
|
1005
|
+
return send(res, 200, sessionId ? scopeChangesToSession(changes, activeStore.doc, sessionId) : changes);
|
|
1006
|
+
}
|
|
1007
|
+
if (route === '/api/operation' && req.method === 'GET') { const record = await activeStore.operation(url.searchParams.get('id') || ''); return send(res, 200, { found: !!record, result: record?.result, recovery: activeStore.blocked }); }
|
|
1008
|
+
if (route === '/api/commit' && req.method === 'POST') {
|
|
1009
|
+
const input = await body(req);
|
|
1010
|
+
if (project.kind === 'git' && viewId === 'main') throw new MapError('READ_ONLY_MAIN', 'All Sessions follows the committed main branch and is read-only', 403);
|
|
1011
|
+
if (actor.kind === 'agent') await fence(viewId);
|
|
1012
|
+
const sessionId = actor.kind === 'agent' ? actor.sessionId : viewId.startsWith('session:') ? viewId.slice('session:'.length) : '';
|
|
1013
|
+
const prepared = await prepareSessionCommit(activeStore, input, actor, sessionId);
|
|
1014
|
+
const result = await activeStore.commit(prepared.input, prepared.actor, () => access.grants(actor.sessionId, activeStore.doc), async () => { if (actor.kind === 'agent' && pendingPeers(viewId).length) throw new MapError('UI_PENDING', 'Page edits are still pending', 409); });
|
|
1015
|
+
return send(res, 200, result);
|
|
1016
|
+
}
|
|
1017
|
+
if (route === '/api/recover' && req.method === 'POST') {
|
|
1018
|
+
isHuman(actor);
|
|
1019
|
+
return send(res, 200, await activeStore.repair(await body(req)));
|
|
1020
|
+
}
|
|
1021
|
+
if (route === '/api/attachments' && req.method === 'POST') {
|
|
1022
|
+
isHuman(actor);
|
|
1023
|
+
if (project.kind === 'git' && viewId === 'main') throw new MapError('READ_ONLY_MAIN', 'All Sessions follows the committed main branch and is read-only', 403);
|
|
1024
|
+
if (activeStore.blocked || activeStore.error) throw new MapError('RECOVERY_REQUIRED', 'Resolve map recovery before uploading', 409);
|
|
1025
|
+
return send(res, 200, await attachmentsFor(activeStore).save(await body(req), activeStore.doc));
|
|
1026
|
+
}
|
|
1027
|
+
if (route === '/api/attachments' && req.method === 'GET') {
|
|
1028
|
+
isHuman(actor);
|
|
1029
|
+
const content = await attachmentsFor(activeStore).read(url.searchParams.get('path'), activeStore.doc);
|
|
1030
|
+
res.writeHead(200, {
|
|
1031
|
+
'Content-Type': 'application/octet-stream',
|
|
1032
|
+
'Content-Disposition': 'attachment',
|
|
1033
|
+
'X-Content-Type-Options': 'nosniff',
|
|
1034
|
+
'Cache-Control': 'no-store',
|
|
1035
|
+
});
|
|
1036
|
+
return res.end(content);
|
|
1037
|
+
}
|
|
1038
|
+
if (route === '/api/access' && req.method === 'GET') {
|
|
1039
|
+
isHuman(actor);
|
|
1040
|
+
const currentSessionId = viewId.startsWith('session:') ? viewId.slice('session:'.length) : null;
|
|
1041
|
+
const snapshot = await access.snapshot(activeStore.doc, currentSessionId);
|
|
1042
|
+
const scopedSnapshot = currentSessionId ? {
|
|
1043
|
+
...snapshot,
|
|
1044
|
+
sessions: snapshot.sessions.filter(item => item.id === currentSessionId),
|
|
1045
|
+
grants: snapshot.grants[currentSessionId] ? { [currentSessionId]: snapshot.grants[currentSessionId] } : {},
|
|
1046
|
+
} : snapshot;
|
|
1047
|
+
return send(res, 200, { ...scopedSnapshot, project: { id: project.projectId, kind: project.kind, github: project.github, bindingRequired: project.bindingRequired, main: mainSource } });
|
|
1048
|
+
}
|
|
1049
|
+
if (route === '/api/access' && req.method === 'POST') {
|
|
1050
|
+
isHuman(actor); const input = await body(req);
|
|
1051
|
+
await activeStore.serial(async () => {
|
|
1052
|
+
await activeStore.refresh(); const ids = entries(activeStore.doc.root);
|
|
1053
|
+
const current = access.accessRecord(input.sessionId);
|
|
1054
|
+
const mode = input.mode === 'all' || Array.isArray(input.addNodes) && current?.mode === 'all' ? 'all' : 'explicit';
|
|
1055
|
+
const requested = mode === 'all' ? [] : Array.isArray(input.addNodes)
|
|
1056
|
+
? [...new Set([...access.grants(input.sessionId, activeStore.doc), ...input.addNodes])]
|
|
1057
|
+
: input.nodes;
|
|
1058
|
+
if (!['all', undefined].includes(input.mode) || !Array.isArray(requested) || requested.some(id => !ids.has(id))) throw new MapError('INVALID_SCOPE', 'Unknown node or access mode in scope');
|
|
1059
|
+
await access.grant(input.sessionId, requested, activeStore.version, mode);
|
|
1060
|
+
const effective = access.grants(input.sessionId, activeStore.doc);
|
|
1061
|
+
await activeStore.recordEvent({ operationId: randomUUID(), fromVersion: activeStore.version, version: activeStore.version, actor, actions: ['grant'], nodeIds: effective, sessionId: input.sessionId, mode });
|
|
1062
|
+
});
|
|
1063
|
+
broadcast('access', {}); return send(res, 200, { saved: true });
|
|
1064
|
+
}
|
|
1065
|
+
if (route === '/api/migration-preview' && req.method === 'POST') {
|
|
1066
|
+
isHuman(actor); const input = await body(req); validate(input.doc);
|
|
1067
|
+
if (input.doc.project !== activeStore.doc.project) throw new MapError('PROJECT_MISMATCH', 'Cache belongs to a different project');
|
|
1068
|
+
await activeStore.serial(() => activeStore.refresh());
|
|
1069
|
+
const backup = path.join(activeStore.runtime, 'migration-' + hash(encode(input.doc)) + '.json');
|
|
1070
|
+
await atomicWrite(backup, encode({ imported: input.doc, disk: activeStore.doc, baseVersion: activeStore.version }));
|
|
1071
|
+
return send(res, 200, { baseVersion: activeStore.version, operations: diffTrees(activeStore.doc.root, input.doc.root), backup, warning: 'Review every replacement and deletion; timestamps do not resolve conflicts.' });
|
|
1072
|
+
}
|
|
1073
|
+
if (route === '/api/projections' && req.method === 'POST') {
|
|
1074
|
+
const input = await body(req); await activeStore.serial(() => activeStore.refresh());
|
|
1075
|
+
if (input.wait) {
|
|
1076
|
+
const version = activeStore.version;
|
|
1077
|
+
const ready = await activeStore.project(activeStore.doc, version);
|
|
1078
|
+
if (!ready) throw new MapError('VERSION_CONFLICT', 'Map changed during index generation', 409);
|
|
1079
|
+
activeStore.projection = { status: 'ready', sourceVersion: version };
|
|
1080
|
+
return send(res, 200, activeStore.projection);
|
|
1081
|
+
}
|
|
1082
|
+
activeStore.scheduleProjection(); return send(res, 202, { status: 'pending' });
|
|
1083
|
+
}
|
|
1084
|
+
throw new MapError('NOT_FOUND', 'Unknown API route', 404);
|
|
1085
|
+
}
|
|
1086
|
+
if (req.method !== 'GET') throw new MapError('METHOD', 'GET required', 405);
|
|
1087
|
+
let file, contentType;
|
|
1088
|
+
if (route === '/' || route === '/prototype/workbench.html') {
|
|
1089
|
+
const html = await fs.readFile(path.join(skillRoot, 'prototype/workbench.html'), 'utf8');
|
|
1090
|
+
const boot = JSON.stringify({ token: humanToken, root: `project:${project.projectId}`, projectId: project.projectId, bindingRequired: project.bindingRequired, instance, interfaceCapabilities: { deviceLogin: true }, ...runtimeIdentity() }).replace(/</g, '\\u003c');
|
|
1091
|
+
const nonce = token();
|
|
1092
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY', 'Content-Security-Policy': `default-src 'self'; script-src 'nonce-${nonce}' 'strict-dynamic'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'` });
|
|
1093
|
+
return res.end(html.replace('<!-- CG_SERVER_BOOT -->', `<script>window.__CG_SERVER=${boot};</script>`).replace(/<script(?=[\s>])/g, `<script nonce="${nonce}"`));
|
|
1094
|
+
}
|
|
1095
|
+
if (route === '/prototype/map-model.mjs') { file = path.join(skillRoot, 'scripts/shared/map-model.mjs'); contentType = 'text/javascript; charset=utf-8'; }
|
|
1096
|
+
else if (['/scripts/shared/map-model.mjs', '/prototype/workbench-sync.mjs', '/prototype/attachments.mjs', '/prototype/coordinator-markdown.mjs', '/prototype/coordinator-working-blot.mjs', '/prototype/vendor/marked.mjs'].includes(route)) { file = path.join(skillRoot, route.slice(1)); contentType = 'text/javascript; charset=utf-8'; }
|
|
1097
|
+
else if (['/prototype/workbench-app.js', '/prototype/workbench-data.js'].includes(route)) { file = path.join(skillRoot, route.slice(1)); contentType = 'text/javascript; charset=utf-8'; }
|
|
1098
|
+
else if (route === '/prototype/workbench.css') { file = path.join(skillRoot, route.slice(1)); contentType = 'text/css; charset=utf-8'; }
|
|
1099
|
+
else if (route === '/prototype/working-blot-atlas.png') { file = path.join(skillRoot, route.slice(1)); contentType = 'image/png'; }
|
|
1100
|
+
else if (route === '/.codex/context/map.json') { file = mainStore.file; contentType = 'application/json; charset=utf-8'; }
|
|
1101
|
+
else if (['/.codex/context/preferences.json', '/.codex/context/candidates.json', '/.codex/context/l1-candidates.json'].includes(route)) { file = path.join(root, route.slice(1)); contentType = 'application/json; charset=utf-8'; }
|
|
1102
|
+
else throw new MapError('NOT_FOUND', 'File not exposed', 404);
|
|
1103
|
+
const content = await fs.readFile(file); res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' }); res.end(content);
|
|
1104
|
+
} catch (e) {
|
|
1105
|
+
if (!res.headersSent) send(res, e.status || (e.code === 'ENOENT' ? 404 : 500), { error: { code: e.code || 'INTERNAL_ERROR', message: e.message, ...e.details } });
|
|
1106
|
+
else res.end();
|
|
1107
|
+
}
|
|
1108
|
+
});
|
|
1109
|
+
server.requestTimeout = 10000; server.headersTimeout = 10000;
|
|
1110
|
+
for (let attempt = 0; ; attempt++) {
|
|
1111
|
+
try { await new Promise((resolve, reject) => { const onError = e => reject(e); server.once('error', onError); server.listen(port === 0 ? 0 : port + attempt, '127.0.0.1', () => { server.off('error', onError); resolve(); }); }); break; }
|
|
1112
|
+
catch (e) { if (e.code !== 'EADDRINUSE' || attempt >= 20 || port === 0) throw e; }
|
|
1113
|
+
}
|
|
1114
|
+
base = `http://127.0.0.1:${server.address().port}`;
|
|
1115
|
+
const state = { ...runtimeIdentity(), root, projectId: project.projectId, worktreeRoot: project.worktreeRoot, worktreeId: project.worktreeId, sharedDir: project.sharedDir, pid: process.pid, instance, url: base + '/prototype/workbench.html', adminToken };
|
|
1116
|
+
await atomicWrite(sharedState, encode(state));
|
|
1117
|
+
if (sharedState !== statePath(root)) await atomicWrite(statePath(root), encode(state));
|
|
1118
|
+
stopAccessWatch = access.watch(() => broadcast('access', {}));
|
|
1119
|
+
await projectDevice();
|
|
1120
|
+
const heartbeat = setInterval(() => broadcast('ping', {}), 10000); heartbeat.unref();
|
|
1121
|
+
const mainRefresh = setInterval(() => { if (project.kind === 'git') refreshProject().catch(() => {}); }, 30000); mainRefresh.unref();
|
|
1122
|
+
let ownershipChecks = 0, ownershipCheckRunning = false;
|
|
1123
|
+
const ownershipWatch = setInterval(async () => {
|
|
1124
|
+
if (close.promise || ownershipCheckRunning) return;
|
|
1125
|
+
ownershipCheckRunning = true;
|
|
1126
|
+
try {
|
|
1127
|
+
const owner = await readJSON(sharedState, null);
|
|
1128
|
+
if (owner?.instance === instance) ownershipChecks = 0;
|
|
1129
|
+
else if (++ownershipChecks >= 2) await close();
|
|
1130
|
+
if (owner?.instance === instance) await ensureDeviceService();
|
|
1131
|
+
} catch {
|
|
1132
|
+
// A transient unreadable state file must not terminate a healthy server.
|
|
1133
|
+
ownershipChecks = 0;
|
|
1134
|
+
} finally { ownershipCheckRunning = false; }
|
|
1135
|
+
}, 500);
|
|
1136
|
+
ownershipWatch.unref();
|
|
1137
|
+
function close() {
|
|
1138
|
+
if (close.promise) return close.promise;
|
|
1139
|
+
close.promise = (async () => {
|
|
1140
|
+
clearInterval(heartbeat);
|
|
1141
|
+
clearInterval(mainRefresh);
|
|
1142
|
+
clearInterval(ownershipWatch);
|
|
1143
|
+
await refreshing?.catch(() => {});
|
|
1144
|
+
stopAccessWatch();
|
|
1145
|
+
clearInterval(deviceHeartbeatTimer); deviceHeartbeatTimer = null;
|
|
1146
|
+
deviceHeartbeatStopping = true;
|
|
1147
|
+
await deviceHeartbeatRunning; await device?.close();
|
|
1148
|
+
// Stop accepting reconnects before draining events or slow projections.
|
|
1149
|
+
const disconnected = new Promise((resolve, reject) => {
|
|
1150
|
+
server.close(error => error ? reject(error) : resolve());
|
|
1151
|
+
});
|
|
1152
|
+
for (const p of peers.values()) p.res?.end();
|
|
1153
|
+
// A CLI stop owns this loopback server and must not leave undici/Node 24
|
|
1154
|
+
// keep-alive connections holding the project lock after the response.
|
|
1155
|
+
server.closeIdleConnections?.();
|
|
1156
|
+
server.closeAllConnections?.();
|
|
1157
|
+
await disconnected;
|
|
1158
|
+
await Promise.all([...syncCoordinators.values()].map(coordinator => coordinator.close()));
|
|
1159
|
+
await Promise.all([...new Set(stores.values())].map(store => store.close()));
|
|
1160
|
+
await Promise.all([...projectionQueues.values()]);
|
|
1161
|
+
if ((await readJSON(sharedState, null))?.instance === instance) await fs.unlink(sharedState);
|
|
1162
|
+
if (sharedState !== statePath(root) && (await readJSON(statePath(root), null))?.instance === instance) await fs.unlink(statePath(root));
|
|
1163
|
+
if ((await readJSON(lock, null))?.instance === instance) await fs.unlink(lock);
|
|
1164
|
+
})();
|
|
1165
|
+
return close.promise;
|
|
1166
|
+
}
|
|
1167
|
+
// Handler needs the shutdown closure after initialization.
|
|
1168
|
+
server.cgClose = close;
|
|
1169
|
+
return { state, project, store: mainStore, stores, access, server, close, humanToken };
|
|
1170
|
+
} catch (e) { stopAccessWatch(); await Promise.all([...syncCoordinators.values()].map(coordinator => coordinator.close())); await Promise.all([...new Set(stores.values())].map(store => store.close())); server?.close(); if ((await readJSON(lock, null))?.instance === instance) await fs.unlink(lock); throw e; }
|
|
1171
|
+
}
|