@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,980 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
+
import { startServer, statePath, projectStatePath, projectLockPath, health, skillRoot, loopbackJSON } from './server.mjs';
|
|
8
|
+
import { resolveProject, ensureProjectBinding, saveMainBinding, bindingStatus, listWorktrees, projectPreferences } from './project.mjs';
|
|
9
|
+
import { readJSON, pause } from '../shared/io.mjs';
|
|
10
|
+
import { MapError } from '../shared/map-model.mjs';
|
|
11
|
+
import { validateMessage } from '../shared/protocol.mjs';
|
|
12
|
+
import { hostAttestedPlatform, recordHostAttestedSession } from './access.mjs';
|
|
13
|
+
import { AgentInbox } from './inbox.mjs';
|
|
14
|
+
import { buildArchiveReconciliation } from './reconcile.mjs';
|
|
15
|
+
import { memoryRequest, memoryStatus, prepareMemory, rebaseMemory, synchronizeMemory, completeMemory, memoryConfigPath, sessionMemoryDir } from './memory.mjs';
|
|
16
|
+
import { atomicWrite, encode } from '../shared/io.mjs';
|
|
17
|
+
import { resolveProjectRoot, bindProject } from './project.mjs';
|
|
18
|
+
import { namedWorkbench, readWorkbenchHealth, verifyWorkbenchUrl } from './named.mjs';
|
|
19
|
+
import { compatibleRuntime, runtimeIdentity, upgradeableRuntime } from './runtime.mjs';
|
|
20
|
+
import { defaultDirectoryAvailability, globalWorkbenchDirectory, readProjectRegistry, registeredProject, rememberProject } from './registry.mjs';
|
|
21
|
+
import { RouteStore } from './portless-routes.mjs';
|
|
22
|
+
import { DeviceConnection } from './protocol-device.mjs';
|
|
23
|
+
import { lookupRepository } from './protocol-repository.mjs';
|
|
24
|
+
import { browserLogin } from './browser-login.mjs';
|
|
25
|
+
const ownFile = fileURLToPath(import.meta.url);
|
|
26
|
+
function documentHasBug(doc, bugId) {
|
|
27
|
+
const pending = doc?.root ? [doc.root] : [];
|
|
28
|
+
while (pending.length) {
|
|
29
|
+
const node = pending.pop();
|
|
30
|
+
if ((node?.bugs || []).some(bug => bug?.id === bugId)) return true;
|
|
31
|
+
pending.push(...(node?.children || []));
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
export async function attachBugWithRecovery(call, sessionId, input) {
|
|
36
|
+
const operationId = `bug:${sessionId}:${input.bug.id}`;
|
|
37
|
+
const prior = await call('/api/operation?id=' + encodeURIComponent(operationId));
|
|
38
|
+
const snapshot = await call('/api/state');
|
|
39
|
+
if (prior.found && documentHasBug(snapshot.doc, input.bug.id)) return { ...prior.result, duplicate: true };
|
|
40
|
+
const recoveryKey = createHash('sha256').update(`${operationId}\0${snapshot.version}`).digest('hex').slice(0, 24);
|
|
41
|
+
const commitOperationId = prior.found ? `bug-recover:${recoveryKey}` : operationId;
|
|
42
|
+
const result = await call('/api/commit', {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
body: {
|
|
45
|
+
operationId: commitOperationId,
|
|
46
|
+
baseVersion: snapshot.version,
|
|
47
|
+
operations: [{ type: 'attach-bug', id: input.node, bug: input.bug }],
|
|
48
|
+
...(prior.found ? { recoveryOf: operationId } : {}),
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
return prior.found ? { ...result, recovered: true } : result;
|
|
52
|
+
}
|
|
53
|
+
export async function updateBugWithRecovery(call, sessionId, input) {
|
|
54
|
+
const operationId = `bug-status:${sessionId}:${input.bug.id}:${input.bug.status}`;
|
|
55
|
+
const prior = await call('/api/operation?id=' + encodeURIComponent(operationId));
|
|
56
|
+
const snapshot = await call('/api/state');
|
|
57
|
+
const exists = documentHasBug(snapshot.doc, input.bug.id);
|
|
58
|
+
if (prior.found && exists) return { ...prior.result, duplicate: true };
|
|
59
|
+
const recoveryOf = `bug:${sessionId}:${input.bug.id}`;
|
|
60
|
+
const recoveryKey = createHash('sha256').update(`${operationId}\0${snapshot.version}`).digest('hex').slice(0, 24);
|
|
61
|
+
const result = await call('/api/commit', {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
body: {
|
|
64
|
+
operationId: exists ? operationId : `bug-status-recover:${recoveryKey}`,
|
|
65
|
+
baseVersion: snapshot.version,
|
|
66
|
+
operations: [{ type: 'update-bug', bug: input.bug }],
|
|
67
|
+
...(!exists ? { recoveryOf } : {}),
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
await call('/api/projections', { method: 'POST', body: { wait: true } });
|
|
71
|
+
return !exists ? { ...result, recovered: true } : result;
|
|
72
|
+
}
|
|
73
|
+
function options(args) {
|
|
74
|
+
const opts = { _: [] };
|
|
75
|
+
for (let i = 0; i < args.length; i++) {
|
|
76
|
+
if (args[i].startsWith('--')) { const key = args[i].slice(2); opts[key] = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : true; }
|
|
77
|
+
else opts._.push(args[i]);
|
|
78
|
+
}
|
|
79
|
+
return opts;
|
|
80
|
+
}
|
|
81
|
+
export function wantsHelp(args) {
|
|
82
|
+
const opt = options(args);
|
|
83
|
+
return opt.help !== undefined || opt._.includes('-h');
|
|
84
|
+
}
|
|
85
|
+
const HELP_EXIT_NOTE = ' -h, --help Print usage and exit. Does not init, start a service, or write .codex/context.';
|
|
86
|
+
function commandHelp(command, parts = []) {
|
|
87
|
+
if (command === 'sync') return `Usage: context-guard sync [status|ensure|prepare|pull|checkpoint|finish] --root <project> --session <id>
|
|
88
|
+
|
|
89
|
+
Uses the project workbench and current Session memory protocol. Authentication:
|
|
90
|
+
context-guard workbench connect --url <cloud-origin> --root <project> --session <id> --wait
|
|
91
|
+
${HELP_EXIT_NOTE}`;
|
|
92
|
+
if (command === 'workbench' && parts[0] === 'connect') return `Usage: context-guard workbench connect --root <project> --url <https-origin> --session <id> [--wait]
|
|
93
|
+
|
|
94
|
+
Default: browser authorization. Show the verification URL/code to the human.
|
|
95
|
+
With --wait, print that link to stderr and wait up to ten minutes; approval automatically saves the device connection.
|
|
96
|
+
Without --wait, return the link immediately; rerun the same command after approval to finish.
|
|
97
|
+
Existing project logins are reused. Passwords are not needed in chat or command arguments.
|
|
98
|
+
Compatibility: --input <private-file|-> explicitly selects the private JSON password login.
|
|
99
|
+
`;
|
|
100
|
+
if (command === 'map' && parts[0] === 'ci') {
|
|
101
|
+
return `Usage: context-guard map ci context --root <worktree> --session <native-ci-id>
|
|
102
|
+
context-guard map ci exchange --root <worktree> --session <native-ci-id> --input <file|->
|
|
103
|
+
|
|
104
|
+
Read context first: its active object supplies taskId, sourceSha, ciTodoRef, references and commands. active:null means no assigned CI task.
|
|
105
|
+
exchange requires ONE protocol message, not {action:...}. The backend supplies the logical developer Session.
|
|
106
|
+
Keep id short (at most 128 characters) and stable on retry. Do not put a session field in these examples.
|
|
107
|
+
|
|
108
|
+
Read a reference using its exact version from active.references:
|
|
109
|
+
{"v":2,"id":"read-ci-todo-1","type":"object.read","payload":{"ref":"<active.ciTodoRef>","version":"<active.references[active.ciTodoRef]>"}}
|
|
110
|
+
|
|
111
|
+
Store actual test evidence under the native CI Session prefix:
|
|
112
|
+
{"v":2,"id":"save-evidence-1","type":"object.put","payload":{"kind":"evidence","ref":"ci:<native-ci-id>:check-1","baseVersion":"","content":{"command":"npm test","exitCode":0,"output":"<actual observed output>"}}}
|
|
113
|
+
|
|
114
|
+
Report every CI TODO using its real item ID and saved evidence reference:
|
|
115
|
+
{"v":2,"id":"report-ci-1","type":"ci.result","payload":{"taskId":"<active.taskId>","sourceSha":"<active.sourceSha>","verdict":"passed","checks":[{"testId":"check-1","todoId":"<actual TODO id>","status":"passed","evidenceRef":"ci:<native-ci-id>:check-1"}]}}
|
|
116
|
+
|
|
117
|
+
verdict/status: passed, failed, incomplete. Failed checks also require a saved reproductionRef.
|
|
118
|
+
Never claim a pass for an unrun check. Do not alter business source, request a development Plan, or supply credentials.
|
|
119
|
+
${HELP_EXIT_NOTE}`;
|
|
120
|
+
}
|
|
121
|
+
if (command === 'map' && parts[0] === 'task' && ['plan', 'handoff'].includes(parts[1])) {
|
|
122
|
+
const handoff = parts[1] === 'handoff';
|
|
123
|
+
return `Usage: context-guard map task ${parts[1]} --root <project> --session <id> --input <file|->
|
|
124
|
+
|
|
125
|
+
Required JSON input:
|
|
126
|
+
${handoff ? '{"operationId":"stable-handoff-id","ciTodo":{"items":[{"id":"check-1","description":"What CI must verify"}]},"unitTests":[{"command":"npm test","status":"passed","evidence":"Actual observed test output"}],"experiences":[]}' : '{"operationId":"stable-plan-id","content":{"paths":["src/example.js"],"steps":["Implementation and verification steps"]}}'}
|
|
127
|
+
|
|
128
|
+
Use --input - to read JSON from stdin. Reuse the same operationId and content after an uncertain reply.
|
|
129
|
+
${handoff ? 'Requires an approved Plan and a clean committed worktree. Success is a server handoff receipt, not a printed summary, CI pass, or merge.' : 'Submits an immutable Plan for Coordinator review; it does not approve development.'}
|
|
130
|
+
${HELP_EXIT_NOTE}`;
|
|
131
|
+
}
|
|
132
|
+
if (command === 'map') {
|
|
133
|
+
return `Usage: context-guard map <action> --root <project> --session <id> [options]
|
|
134
|
+
|
|
135
|
+
Actions:
|
|
136
|
+
main read Read the authoritative Main Map without binding a Session
|
|
137
|
+
main apply Disabled leftover: only Coordinator writes Main structure
|
|
138
|
+
status Print map version and recovery state
|
|
139
|
+
read Read the map or one --node
|
|
140
|
+
changes Read changes after --cursor
|
|
141
|
+
inbox Read the Session inbox (--start for the first page)
|
|
142
|
+
ack Acknowledge --receipt
|
|
143
|
+
watch Wait up to --wait-ms for inbox events
|
|
144
|
+
apply Commit --input operations
|
|
145
|
+
operation Look up --id
|
|
146
|
+
projections Rebuild derived cards
|
|
147
|
+
reconcile Archive reconciliation from --input
|
|
148
|
+
execution Read the actual assigned task and approved Plan
|
|
149
|
+
task plan Submit a Plan; use map task plan --help for JSON
|
|
150
|
+
task handoff Deliver committed SHA and CI evidence; use map task handoff --help
|
|
151
|
+
ci context Read the CI assignment and exact source SHA
|
|
152
|
+
ci exchange Submit CI evidence/result via --input JSON
|
|
153
|
+
coordinator Call one built-in Coordinator tool. JSON: {"operationId","name","input","conversationId"?}
|
|
154
|
+
|
|
155
|
+
Options:
|
|
156
|
+
--root <dir> Project root (default: current directory)
|
|
157
|
+
--session <id> Real lifecycle Session ID (or CODEX_THREAD_ID)
|
|
158
|
+
--node <id> Node id for map read
|
|
159
|
+
--cursor <token> Change cursor
|
|
160
|
+
--input <file|-> JSON request file or stdin
|
|
161
|
+
--id <operation> Operation id
|
|
162
|
+
--receipt <id> Inbox receipt
|
|
163
|
+
--wait-ms <n> Watch timeout
|
|
164
|
+
--start Start a new inbox page
|
|
165
|
+
|
|
166
|
+
Developer Main apply input:
|
|
167
|
+
{"operationId":"stable-id","baseVersion":"observed-version","changes":[...]}
|
|
168
|
+
Only explicitly allowlisted project clients can create, rename, update or move nodes. Delete, access and record edits are rejected.
|
|
169
|
+
${HELP_EXIT_NOTE}`;
|
|
170
|
+
}
|
|
171
|
+
if (command === 'memory') {
|
|
172
|
+
return `Usage: context-guard memory [status|sync|prepare|rebase|configure|complete|publish|history|restore|file] [options]
|
|
173
|
+
|
|
174
|
+
Options:
|
|
175
|
+
--root <dir> Project root (default: current directory)
|
|
176
|
+
--session <id> Session ID
|
|
177
|
+
--input <file|-> Private JSON for configure/publish/restore
|
|
178
|
+
file --scope main|session --path <relative Markdown path> [--version <revision>]
|
|
179
|
+
${HELP_EXIT_NOTE}`;
|
|
180
|
+
}
|
|
181
|
+
if (command === 'preferences') {
|
|
182
|
+
return `Usage: context-guard preferences [--root <project>] [--language <code>]
|
|
183
|
+
|
|
184
|
+
Options:
|
|
185
|
+
--root <dir> Project root (default: current directory)
|
|
186
|
+
--language <code> Persist the project record language
|
|
187
|
+
${HELP_EXIT_NOTE}`;
|
|
188
|
+
}
|
|
189
|
+
if (command === 'serve') {
|
|
190
|
+
return `Usage: context-guard workbench serve --root <project> [--host 127.0.0.1] [--port 8877]
|
|
191
|
+
|
|
192
|
+
Foreground Node backend. Prefer \`context-guard workbench\` to start the managed service.
|
|
193
|
+
${HELP_EXIT_NOTE}`;
|
|
194
|
+
}
|
|
195
|
+
if (command === 'workbench') {
|
|
196
|
+
return `Usage: context-guard workbench [options]
|
|
197
|
+
|
|
198
|
+
Start or print the project workbench URL. --session is optional.
|
|
199
|
+
|
|
200
|
+
context-guard workbench --root <project>
|
|
201
|
+
context-guard workbench --diagnose --root <project> [--session <id>]
|
|
202
|
+
context-guard workbench --list [--root <project>]
|
|
203
|
+
context-guard workbench --stop --root <project>
|
|
204
|
+
context-guard workbench --binding-status --root <project> [--session <id>]
|
|
205
|
+
context-guard workbench --bind-main <branch> [--remote <name>] --root <project>
|
|
206
|
+
context-guard workbench --local-main <branch> --root <project>
|
|
207
|
+
|
|
208
|
+
Options:
|
|
209
|
+
--root <dir> Project root (default: current directory)
|
|
210
|
+
--session <id> Bind or pin this Session
|
|
211
|
+
--role <name> Context role: executor (default) or coordinator
|
|
212
|
+
--port <n> Backend port (default: 8877)
|
|
213
|
+
--stop Stop the project workbench
|
|
214
|
+
--list List registered workbenches
|
|
215
|
+
--diagnose Inspect runtime without starting a service
|
|
216
|
+
--binding-status Print binding and runtime verification
|
|
217
|
+
--bind-main <branch> Confirm the remote Main branch
|
|
218
|
+
--local-main <branch> Confirm a local Main branch
|
|
219
|
+
--remote <name> Git remote for --bind-main (default: origin)
|
|
220
|
+
--rebind Move an existing Session after confirmation
|
|
221
|
+
--workbench-url <url> Bind to an existing workbench URL
|
|
222
|
+
${HELP_EXIT_NOTE}`;
|
|
223
|
+
}
|
|
224
|
+
return `Usage: context-guard <workbench|map|memory|preferences> [options]
|
|
225
|
+
|
|
226
|
+
Use --help or -h after a command to print that command's usage and exit.
|
|
227
|
+
${HELP_EXIT_NOTE}`;
|
|
228
|
+
}
|
|
229
|
+
function processAlive(pid) {
|
|
230
|
+
if (!Number.isInteger(pid) || pid < 1) return false;
|
|
231
|
+
try { process.kill(pid, 0); return true; }
|
|
232
|
+
catch (error) { return error.code !== 'ESRCH'; }
|
|
233
|
+
}
|
|
234
|
+
async function inspectState(file, state, projectId, worktreeRoots = []) {
|
|
235
|
+
if (!state || typeof state.url !== 'string') return null;
|
|
236
|
+
const live = await health(state);
|
|
237
|
+
const liveRoot = live?.root ? await fs.realpath(live.root).catch(() => live.root) : null;
|
|
238
|
+
const sameInstance = !!state.instance && live?.instance === state.instance;
|
|
239
|
+
const sameRoot = !!liveRoot && worktreeRoots.includes(liveRoot);
|
|
240
|
+
const belongs = live ? live.projectId === projectId || sameInstance && sameRoot || (!live.projectId && sameRoot) : true;
|
|
241
|
+
const ownerAlive = !live && processAlive(state.pid);
|
|
242
|
+
return {
|
|
243
|
+
file, state, live, belongs, ownerAlive,
|
|
244
|
+
status: !live ? ownerAlive ? 'unknown' : 'dead'
|
|
245
|
+
: !belongs ? 'foreign'
|
|
246
|
+
: compatibleRuntime(live) ? 'ready'
|
|
247
|
+
: upgradeableRuntime(live) ? 'upgradeable' : 'legacy',
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
export async function serviceInventory(project) {
|
|
251
|
+
const worktreeRoots = await listWorktrees(project);
|
|
252
|
+
const stateFiles = [...new Set([projectStatePath(project), ...worktreeRoots.map(statePath)])];
|
|
253
|
+
const records = [];
|
|
254
|
+
for (const file of stateFiles) {
|
|
255
|
+
const state = await readJSON(file, null);
|
|
256
|
+
const record = await inspectState(file, state, project.projectId, worktreeRoots);
|
|
257
|
+
if (record) records.push(record);
|
|
258
|
+
}
|
|
259
|
+
const unique = [];
|
|
260
|
+
for (const record of records) {
|
|
261
|
+
const identity = record.live?.instance || record.state?.instance || `${record.state?.pid || ''}:${record.state?.url || record.file}`;
|
|
262
|
+
if (!unique.some(item => item.identity === identity)) unique.push({ ...record, identity, sources: ['state'] });
|
|
263
|
+
}
|
|
264
|
+
return { worktreeRoots, records: unique };
|
|
265
|
+
}
|
|
266
|
+
function publicService(record) {
|
|
267
|
+
return {
|
|
268
|
+
stateFile: record.file,
|
|
269
|
+
status: record.status,
|
|
270
|
+
pid: record.live?.pid || record.state?.pid || null,
|
|
271
|
+
url: record.state?.url || null,
|
|
272
|
+
root: record.live?.root || record.state?.root || null,
|
|
273
|
+
projectId: record.live?.projectId || null,
|
|
274
|
+
worktreeRoot: record.live?.worktreeRoot || null,
|
|
275
|
+
instance: record.live?.instance || record.state?.instance || null,
|
|
276
|
+
protocol: record.live?.protocol || record.state?.protocol || null,
|
|
277
|
+
runtimeSchema: record.live?.runtimeSchema || null,
|
|
278
|
+
buildId: record.live?.buildId || null,
|
|
279
|
+
capabilities: Array.isArray(record.live?.capabilities) ? record.live.capabilities : [],
|
|
280
|
+
retireKey: record.live?.pid && record.live?.instance ? `${record.live.pid}:${record.live.instance}` : null,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
async function firstResolvableProject(roots) {
|
|
284
|
+
for (const root of roots) {
|
|
285
|
+
if (typeof root !== 'string' || !path.isAbsolute(root)) continue;
|
|
286
|
+
try { return await resolveProject(root); } catch {}
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
function routeOrigin(route, proxy) {
|
|
291
|
+
if (!proxy?.base) return '';
|
|
292
|
+
try { return `http://${route.hostname}:${new URL(proxy.base).port}`; } catch { return ''; }
|
|
293
|
+
}
|
|
294
|
+
async function inspectRoute(route, origin, projectId) {
|
|
295
|
+
const directState = { url: `http://127.0.0.1:${route.port}/prototype/workbench.html`, instance: route.instance, root: route.root };
|
|
296
|
+
const direct = await inspectState(`route:${route.hostname}`, directState, projectId, [route.root]);
|
|
297
|
+
let named = { status: 'stale', url: origin || null };
|
|
298
|
+
if (origin) {
|
|
299
|
+
try {
|
|
300
|
+
const { response, value } = await readWorkbenchHealth(origin);
|
|
301
|
+
if (response.ok) {
|
|
302
|
+
const matchesProject = !route.projectKey || value?.projectId === route.projectKey;
|
|
303
|
+
const matchesInstance = value?.instance === route.instance;
|
|
304
|
+
named = { status: matchesProject && matchesInstance ? 'ready' : 'mismatch', url: origin };
|
|
305
|
+
}
|
|
306
|
+
} catch {}
|
|
307
|
+
}
|
|
308
|
+
return { direct, named };
|
|
309
|
+
}
|
|
310
|
+
async function projectDisplayName(record, routes, roots) {
|
|
311
|
+
if (record?.name) return record.name;
|
|
312
|
+
if (routes[0]?.hostname) return routes[0].hostname.replace(/\.localhost$/, '');
|
|
313
|
+
for (const root of roots) {
|
|
314
|
+
const map = await readJSON(path.join(root, '.codex/context/map.json'), null).catch(() => null);
|
|
315
|
+
if (typeof map?.project === 'string' && map.project.trim()) return map.project.trim();
|
|
316
|
+
}
|
|
317
|
+
return roots[0] ? path.basename(roots[0]) : 'unknown-project';
|
|
318
|
+
}
|
|
319
|
+
function publicProject(project) {
|
|
320
|
+
return {
|
|
321
|
+
name: project.name,
|
|
322
|
+
url: project.url || null,
|
|
323
|
+
status: project.status,
|
|
324
|
+
backendStatus: project.backendStatus,
|
|
325
|
+
routeStatus: project.routeStatus,
|
|
326
|
+
registered: project.registered,
|
|
327
|
+
runningInstances: project.runningInstances,
|
|
328
|
+
needsAttention: project.status !== 'ready' && project.status !== 'stopped',
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
export async function globalWorkbenchInventory({ dir = globalWorkbenchDirectory(), currentRoot = '' } = {}) {
|
|
332
|
+
const registry = await readProjectRegistry({ dir });
|
|
333
|
+
const routes = new RouteStore(dir).loadRoutes();
|
|
334
|
+
const proxy = await readJSON(path.join(dir, 'proxy.json'), null);
|
|
335
|
+
const projects = new Map(registry.projects.map(record => [record.projectId, { record, routes: [] }]));
|
|
336
|
+
await Promise.all(routes.map(async route => {
|
|
337
|
+
let key = route.projectKey || '';
|
|
338
|
+
if (!key) key = (await firstResolvableProject([route.root]))?.projectId || `route:${route.hostname}`;
|
|
339
|
+
const target = projects.get(key) || { record: null, routes: [] };
|
|
340
|
+
target.routes.push(route); projects.set(key, target);
|
|
341
|
+
}));
|
|
342
|
+
let currentProjectId = '';
|
|
343
|
+
if (currentRoot) currentProjectId = (await resolveProject(currentRoot).catch(() => null))?.projectId || '';
|
|
344
|
+
const inspected = await Promise.all([...projects.entries()].map(async ([key, entry]) => {
|
|
345
|
+
const roots = [...new Set([...(entry.record?.roots || []), ...entry.routes.map(route => route.root)])];
|
|
346
|
+
const resolved = await firstResolvableProject(roots);
|
|
347
|
+
let records = [];
|
|
348
|
+
if (resolved && (resolved.projectId === key || !entry.record)) {
|
|
349
|
+
records = (await serviceInventory(resolved).catch(() => ({ records: [] }))).records;
|
|
350
|
+
} else if (entry.record?.stateFile) {
|
|
351
|
+
const state = await readJSON(entry.record.stateFile, null);
|
|
352
|
+
const record = await inspectState(entry.record.stateFile, state, key, roots);
|
|
353
|
+
if (record) records.push({ ...record, identity: record.live?.instance || record.state?.instance || entry.record.stateFile, sources: ['registry'] });
|
|
354
|
+
}
|
|
355
|
+
const routeResults = await Promise.all(entry.routes.map(route => inspectRoute(
|
|
356
|
+
route,
|
|
357
|
+
entry.record?.origin && new URL(entry.record.origin).hostname === route.hostname ? entry.record.origin : routeOrigin(route, proxy),
|
|
358
|
+
key,
|
|
359
|
+
)));
|
|
360
|
+
for (let index = 0; index < routeResults.length; index++) {
|
|
361
|
+
const record = routeResults[index].direct;
|
|
362
|
+
if (!record) continue;
|
|
363
|
+
const identity = record.live?.instance || record.state?.instance || `route:${entry.routes[index].hostname}`;
|
|
364
|
+
const existing = records.find(item => item.identity === identity);
|
|
365
|
+
if (existing) existing.sources = [...new Set([...(existing.sources || []), 'route'])];
|
|
366
|
+
else records.push({ ...record, identity, sources: ['route'] });
|
|
367
|
+
}
|
|
368
|
+
const owned = records.filter(record => record.belongs && record.live);
|
|
369
|
+
const unknown = records.filter(record => record.belongs && record.status === 'unknown');
|
|
370
|
+
const ready = owned.filter(record => record.status === 'ready');
|
|
371
|
+
const legacy = owned.filter(record => record.status === 'legacy');
|
|
372
|
+
const upgradeable = owned.filter(record => record.status === 'upgradeable');
|
|
373
|
+
const namedReady = routeResults.some(result => result.named.status === 'ready');
|
|
374
|
+
const namedMismatch = routeResults.some(result => result.named.status === 'mismatch');
|
|
375
|
+
const routeStatus = namedReady ? 'ready' : namedMismatch ? 'mismatch' : entry.routes.length ? 'stale' : 'missing';
|
|
376
|
+
const backendStatus = owned.length > 1 ? 'duplicate' : ready.length ? 'ready' : legacy.length ? 'legacy' : upgradeable.length ? 'upgrade-required' : unknown.length ? 'unknown' : 'stopped';
|
|
377
|
+
const status = backendStatus === 'duplicate' ? 'duplicate'
|
|
378
|
+
: backendStatus === 'upgrade-required' ? 'upgrade-required'
|
|
379
|
+
: backendStatus === 'legacy' ? 'legacy'
|
|
380
|
+
: backendStatus === 'unknown' ? 'unknown'
|
|
381
|
+
: backendStatus === 'stopped' ? entry.routes.length ? 'route-stale' : 'stopped'
|
|
382
|
+
: routeStatus === 'ready' ? 'ready' : routeStatus === 'mismatch' ? 'route-mismatch' : 'direct-only';
|
|
383
|
+
const origin = entry.record?.origin || routeResults.find(result => result.named.url)?.named.url || '';
|
|
384
|
+
return {
|
|
385
|
+
projectId: key,
|
|
386
|
+
name: await projectDisplayName(entry.record, entry.routes, roots),
|
|
387
|
+
url: origin ? new URL('/prototype/workbench.html', origin).href : null,
|
|
388
|
+
status, backendStatus, routeStatus,
|
|
389
|
+
registered: !!entry.record,
|
|
390
|
+
runningInstances: owned.length,
|
|
391
|
+
records,
|
|
392
|
+
current: key === currentProjectId,
|
|
393
|
+
};
|
|
394
|
+
}));
|
|
395
|
+
const runningIdentities = new Set();
|
|
396
|
+
for (const project of inspected) for (const record of project.records) {
|
|
397
|
+
if (record.belongs && record.live) runningIdentities.add(record.live.instance || record.identity);
|
|
398
|
+
}
|
|
399
|
+
const projectsPublic = inspected.map(publicProject).sort((a, b) => a.name.localeCompare(b.name));
|
|
400
|
+
const current = inspected.find(project => project.current);
|
|
401
|
+
return {
|
|
402
|
+
registeredCount: registry.projects.length,
|
|
403
|
+
projectCount: inspected.length,
|
|
404
|
+
runningCount: runningIdentities.size,
|
|
405
|
+
readyCount: inspected.filter(project => project.status === 'ready').length,
|
|
406
|
+
stoppedCount: inspected.filter(project => project.status === 'stopped').length,
|
|
407
|
+
attentionCount: inspected.filter(project => project.status !== 'ready' && project.status !== 'stopped').length,
|
|
408
|
+
projects: projectsPublic,
|
|
409
|
+
currentProject: current ? publicProject(current) : null,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
async function initialize(root) {
|
|
413
|
+
try { await fs.access(path.join(root, '.codex/context/map.json')); return; } catch {}
|
|
414
|
+
const commands = process.platform === 'win32' ? ['python', 'python3'] : ['python3', 'python'];
|
|
415
|
+
for (const command of commands) {
|
|
416
|
+
const result = spawnSync(command, [path.join(skillRoot, 'scripts/context_guard.py'), 'init', '--root', root], { encoding: 'utf8', windowsHide: true });
|
|
417
|
+
if (result.error?.code === 'ENOENT') continue;
|
|
418
|
+
if (result.status === 0) return;
|
|
419
|
+
throw new Error(result.stderr || 'Project initialization failed');
|
|
420
|
+
}
|
|
421
|
+
throw new Error('Python is still required for project initialization');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export async function diagnoseWorkbench(root, sessionId = '') {
|
|
425
|
+
const openedRoot = await fs.realpath(path.resolve(root));
|
|
426
|
+
const project = await resolveProject(openedRoot);
|
|
427
|
+
const cloud = await readJSON(memoryConfigPath(project), null);
|
|
428
|
+
const binding = await bindingStatus(project, sessionId);
|
|
429
|
+
const inventory = await serviceInventory(project);
|
|
430
|
+
const projectServices = inventory.records.filter(item => item.belongs && item.live);
|
|
431
|
+
const ready = projectServices.filter(item => item.status === 'ready');
|
|
432
|
+
const upgradeable = projectServices.filter(item => item.status === 'upgradeable');
|
|
433
|
+
const legacy = projectServices.filter(item => item.status === 'legacy');
|
|
434
|
+
const unknown = inventory.records.filter(item => item.belongs && item.status === 'unknown');
|
|
435
|
+
const duplicates = ready.length > 1;
|
|
436
|
+
let named = { status: 'not-configured', url: null };
|
|
437
|
+
const namedFile = project.kind === 'git' ? path.join(project.sharedDir, 'named-entry.json') : path.join(openedRoot, '.codex/context/private/named-entry.json');
|
|
438
|
+
const registry = await registeredProject(project).catch(() => null);
|
|
439
|
+
const namedEntry = await readJSON(namedFile, null);
|
|
440
|
+
const canonicalOrigin = namedEntry?.origin || registry?.origin || '';
|
|
441
|
+
if (canonicalOrigin && !cloud?.url) {
|
|
442
|
+
const expected = ready[0]?.live;
|
|
443
|
+
if (!expected) named = { status: legacy.length ? 'legacy-runtime' : upgradeable.length ? 'upgrade-required' : 'backend-unavailable', url: canonicalOrigin + '/prototype/workbench.html' };
|
|
444
|
+
else {
|
|
445
|
+
try { await verifyWorkbenchUrl(canonicalOrigin + '/prototype/workbench.html', expected); named = { status: 'ready', url: canonicalOrigin + '/prototype/workbench.html' }; }
|
|
446
|
+
catch (error) { named = { status: 'mismatch', url: canonicalOrigin + '/prototype/workbench.html', error: error.message }; }
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const runtimeStatus = legacy.length ? 'legacy' : upgradeable.length ? 'upgrade-required' : duplicates ? 'duplicate' : ready.length === 1 ? (named.status === 'mismatch' ? 'named-mismatch' : 'ready') : unknown.length ? 'unknown' : 'stopped';
|
|
450
|
+
const namedRequired = !cloud?.url && process.env.CONTEXT_GUARD_NAMED_WORKBENCH !== '0';
|
|
451
|
+
const candidateUrl = cloud?.url ? ready[0]?.state?.url || null : namedRequired
|
|
452
|
+
? named.url || binding.session?.workbenchUrl || null
|
|
453
|
+
: binding.session?.workbenchUrl || named.url || null;
|
|
454
|
+
let verified = false;
|
|
455
|
+
if (binding.session?.bound && candidateUrl && ready.length === 1 && !duplicates) {
|
|
456
|
+
try {
|
|
457
|
+
const target = new URL(candidateUrl);
|
|
458
|
+
if (namedRequired && !target.hostname.endsWith('.localhost')) throw new Error('Named project URL required');
|
|
459
|
+
await verifyWorkbenchUrl(candidateUrl, ready[0].live); verified = true;
|
|
460
|
+
} catch {}
|
|
461
|
+
}
|
|
462
|
+
return {
|
|
463
|
+
expectedRuntime: runtimeIdentity(),
|
|
464
|
+
project: { id: project.projectId, kind: project.kind, root: openedRoot, worktreeRoot: project.worktreeRoot, worktreeId: project.worktreeId, sharedDir: project.sharedDir, main: project.binding?.main || null, bindingRequired: project.bindingRequired },
|
|
465
|
+
runtime: { status: runtimeStatus, services: inventory.records.map(publicService), named, registry },
|
|
466
|
+
...binding,
|
|
467
|
+
workbenchUrl: cloud?.projectId ? new URL(`/projects/${encodeURIComponent(cloud.projectId)}`, cloud.url).href : candidateUrl,
|
|
468
|
+
session: { ...binding.session, verified },
|
|
469
|
+
migrationRequired: legacy.length > 0 || duplicates || named.status === 'mismatch',
|
|
470
|
+
migrationPlan: [...legacy, ...(duplicates ? ready.slice(1) : [])].map(item => ({
|
|
471
|
+
retireKey: publicService(item).retireKey,
|
|
472
|
+
root: item.live?.root || item.state?.root || null,
|
|
473
|
+
reason: item.status === 'legacy' ? 'incompatible-runtime' : 'duplicate-project-owner',
|
|
474
|
+
})),
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function stopUpgradeable(project, record) {
|
|
479
|
+
const state = record.state;
|
|
480
|
+
if (!state?.adminToken || !record.live?.instance) throw new MapError('LEGACY_SERVICE', 'The older workbench cannot be upgraded safely because its identity capability is incomplete', 409);
|
|
481
|
+
try { await request(state, '/api/stop', { method: 'POST', body: {} }); }
|
|
482
|
+
catch (error) {
|
|
483
|
+
// Another launcher may have won the same upgrade race. A disappeared or
|
|
484
|
+
// replaced instance is success for this launcher; only the unchanged old
|
|
485
|
+
// owner is a real stop failure.
|
|
486
|
+
const current = await health(state);
|
|
487
|
+
if (!current || current.instance !== record.live.instance) return;
|
|
488
|
+
throw new MapError('UPGRADE_PENDING', 'The older workbench could not be stopped safely. Save open page drafts and retry; no replacement was started.', 409, { cause: error.code || error.message });
|
|
489
|
+
}
|
|
490
|
+
const deadline = Date.now() + 45000;
|
|
491
|
+
while (Date.now() < deadline) {
|
|
492
|
+
const [live, lock] = await Promise.all([health(state), readJSON(projectLockPath(project), null)]);
|
|
493
|
+
if (!live && lock?.instance !== record.live.instance) return;
|
|
494
|
+
await pause(25);
|
|
495
|
+
}
|
|
496
|
+
throw new MapError('UPGRADE_PENDING', 'The older workbench has not released the project lock; no replacement was started', 503);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function migrateServices(root, retireKeys) {
|
|
500
|
+
const project = await resolveProject(root);
|
|
501
|
+
if (project.kind !== 'git') throw new MapError('MIGRATION_UNSUPPORTED', 'Legacy multi-worktree migration requires a Git project', 409);
|
|
502
|
+
const inventory = await serviceInventory(project);
|
|
503
|
+
const requested = new Set(String(retireKeys || '').split(',').map(value => value.trim()).filter(Boolean));
|
|
504
|
+
if (!requested.size) throw new MapError('MIGRATION_CONFIRMATION_REQUIRED', 'Pass the exact comma-separated pid:instance retire keys from workbench --diagnose', 409);
|
|
505
|
+
const candidates = inventory.records.filter(item => item.belongs && item.live && requested.has(`${item.live.pid}:${item.live.instance}`));
|
|
506
|
+
if (candidates.length !== requested.size) throw new MapError('MIGRATION_TARGET_CHANGED', 'A requested service identity is missing or changed; diagnose again', 409);
|
|
507
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
508
|
+
const backupDir = path.join(project.sharedDir, 'migration-backups', timestamp);
|
|
509
|
+
await fs.mkdir(backupDir, { recursive: true, mode: 0o700 });
|
|
510
|
+
const manifest = [];
|
|
511
|
+
for (const item of candidates) {
|
|
512
|
+
const serviceRoot = await fs.realpath(item.live.root || item.state.root);
|
|
513
|
+
const source = path.join(serviceRoot, '.codex/context');
|
|
514
|
+
const target = path.join(backupDir, createHash('sha256').update(serviceRoot).digest('hex').slice(0, 16));
|
|
515
|
+
await fs.cp(source, target, { recursive: true, errorOnExist: true, force: false });
|
|
516
|
+
manifest.push({ retireKey: `${item.live.pid}:${item.live.instance}`, source, target, status: item.status });
|
|
517
|
+
}
|
|
518
|
+
await atomicWrite(path.join(backupDir, 'manifest.json'), encode({ projectId: project.projectId, createdAt: new Date().toISOString(), services: manifest }));
|
|
519
|
+
for (const item of candidates) {
|
|
520
|
+
const current = await health(item.state);
|
|
521
|
+
if (!current || current.pid !== item.live.pid || current.instance !== item.live.instance) throw new MapError('MIGRATION_TARGET_CHANGED', 'Service identity changed after backup; nothing further was signalled', 409, { backupDir });
|
|
522
|
+
}
|
|
523
|
+
for (const item of candidates) process.kill(item.live.pid, 'SIGTERM');
|
|
524
|
+
const deadline = Date.now() + 12000;
|
|
525
|
+
for (const item of candidates) {
|
|
526
|
+
while (Date.now() < deadline && await health(item.state)) await pause(50);
|
|
527
|
+
if (await health(item.state)) throw new MapError('MIGRATION_STOP_TIMEOUT', 'A backed-up service did not stop after SIGTERM; it was not force-killed', 503, { backupDir, retireKey: `${item.live.pid}:${item.live.instance}` });
|
|
528
|
+
}
|
|
529
|
+
return { migrated: true, backupDir, retired: manifest.map(item => item.retireKey), restartRequired: false, next: 'Run workbench with the intended Session; one compatible project service will be created if needed.' };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async function stateForWorkbenchUrl(project, value) {
|
|
533
|
+
let target;
|
|
534
|
+
try { target = new URL(String(value)); } catch { throw new MapError('INVALID_WORKBENCH_URL', 'Provide the complete project workbench URL', 400); }
|
|
535
|
+
if (target.protocol !== 'http:' || !(target.hostname === '127.0.0.1' || target.hostname === 'localhost' || target.hostname.endsWith('.localhost'))) {
|
|
536
|
+
throw new MapError('INVALID_WORKBENCH_URL', 'A local workbench URL must use http and localhost', 400);
|
|
537
|
+
}
|
|
538
|
+
let response, advertised;
|
|
539
|
+
try {
|
|
540
|
+
({ response, value: advertised } = await readWorkbenchHealth(target));
|
|
541
|
+
} catch (cause) { throw new MapError('WORKBENCH_UNAVAILABLE', `The supplied workbench URL is unavailable: ${cause.message}`, 503); }
|
|
542
|
+
if (!response.ok || !compatibleRuntime(advertised)) throw new MapError('LEGACY_SERVICE', 'The supplied URL is not a compatible Context Guard workbench', 409);
|
|
543
|
+
if (advertised.projectId !== project.projectId) throw new MapError('PROJECT_MISMATCH', 'The supplied URL belongs to a different Git project', 409, { expectedProjectId: project.projectId, actualProjectId: advertised.projectId });
|
|
544
|
+
const state = await readJSON(projectStatePath(project), null);
|
|
545
|
+
const stateHealth = state && await health(state);
|
|
546
|
+
if (!stateHealth || stateHealth.instance !== advertised.instance) throw new MapError('WORKBENCH_IDENTITY_MISMATCH', 'The URL is not the service registered for this Git project', 409);
|
|
547
|
+
return state;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const directoryFailureLog = /EACCES|EPERM|ENOTDIR|EROFS|EEXIST|permission denied|not a directory|read-only file system/i;
|
|
551
|
+
export function logMentionsDirectoryFailure(log, directory) {
|
|
552
|
+
const text = String(log || '');
|
|
553
|
+
// CLI failures are JSON lines: Windows backslashes are escaped on disk.
|
|
554
|
+
// Compare decoded messages as well as plain logs, keeping the exact path gate.
|
|
555
|
+
const messages = [text, ...text.split(/\r?\n/).map(line => {
|
|
556
|
+
try { return JSON.parse(line)?.error?.message; } catch { return null; }
|
|
557
|
+
})];
|
|
558
|
+
return !!directory && messages.some(message => typeof message === 'string' && message.includes(directory) && directoryFailureLog.test(message));
|
|
559
|
+
}
|
|
560
|
+
export function startFailedMessage({ log = '', directory } = {}) {
|
|
561
|
+
const base = 'Node workbench did not become healthy; inspect private/node-workbench.log';
|
|
562
|
+
if (!directory || directory.overridden || !directory.unavailable || !logMentionsDirectoryFailure(log, directory.path)) return base;
|
|
563
|
+
return `${base} The default directory ${directory.path} is unavailable. Override it as described in references/named-workbench.md.`;
|
|
564
|
+
}
|
|
565
|
+
async function failStart(logPath) {
|
|
566
|
+
const log = await fs.readFile(logPath, 'utf8').catch(() => '');
|
|
567
|
+
const directory = await defaultDirectoryAvailability();
|
|
568
|
+
throw new MapError('START_FAILED', startFailedMessage({ log, directory }), 503);
|
|
569
|
+
}
|
|
570
|
+
export async function ensureServer(root, port = 8877) {
|
|
571
|
+
root = await resolveProjectRoot(root);
|
|
572
|
+
await initialize(root);
|
|
573
|
+
root = await fs.realpath(root);
|
|
574
|
+
const project = await ensureProjectBinding(await resolveProject(root));
|
|
575
|
+
const sharedState = projectStatePath(project);
|
|
576
|
+
const inventory = await serviceInventory(project);
|
|
577
|
+
const liveProject = inventory.records.filter(item => item.belongs && item.live);
|
|
578
|
+
const legacy = liveProject.filter(item => item.status === 'legacy');
|
|
579
|
+
if (legacy.length) throw new MapError('LEGACY_SERVICE', 'An incompatible worktree service is active. Run workbench --diagnose, export its cache, then use the explicit migration command.', 409, { services: legacy.map(publicService) });
|
|
580
|
+
const upgradeable = liveProject.filter(item => item.status === 'upgradeable');
|
|
581
|
+
if (upgradeable.length > 1) throw new MapError('DUPLICATE_SERVICE', 'More than one older service owns this Git project. Diagnose and migrate exact instances.', 409, { services: upgradeable.map(publicService) });
|
|
582
|
+
if (upgradeable.length === 1) {
|
|
583
|
+
await stopUpgradeable(project, upgradeable[0]);
|
|
584
|
+
}
|
|
585
|
+
const ready = liveProject.filter(item => item.status === 'ready');
|
|
586
|
+
if (ready.length > 1) throw new MapError('DUPLICATE_SERVICE', 'More than one compatible service owns this Git project. Run workbench --diagnose and migrate exact instances; no process was stopped.', 409, { services: ready.map(publicService) });
|
|
587
|
+
let state = ready[0]?.state || null, live = ready[0]?.live || null;
|
|
588
|
+
if (state && live) {
|
|
589
|
+
if (!state.adminToken) throw new MapError('LEGACY_SERVICE', 'Workbench state lacks its local CLI capability; migrate it explicitly', 409);
|
|
590
|
+
if (sharedState !== statePath(root)) await fs.mkdir(path.dirname(statePath(root)), { recursive: true }).then(() => fs.writeFile(statePath(root), JSON.stringify(state, null, 2) + '\n'));
|
|
591
|
+
await rememberProject(project, { state: { ...state, ...live } });
|
|
592
|
+
return state;
|
|
593
|
+
}
|
|
594
|
+
await fs.mkdir(path.join(root, '.codex/context/private'), { recursive: true, mode: 0o700 });
|
|
595
|
+
const log = await fs.open(path.join(root, '.codex/context/private/node-workbench.log'), 'a', 0o600);
|
|
596
|
+
const child = spawn(process.execPath, [ownFile, 'serve', '--root', root, '--port', String(port)], { detached: true, windowsHide: true, stdio: ['ignore', log.fd, log.fd] });
|
|
597
|
+
child.unref(); await log.close();
|
|
598
|
+
const logPath = path.join(root, '.codex/context/private/node-workbench.log');
|
|
599
|
+
const deadline = Date.now() + 12000;
|
|
600
|
+
while (Date.now() < deadline) {
|
|
601
|
+
await pause(60); state = await readJSON(sharedState, null).catch(() => null) || await readJSON(statePath(root), null).catch(() => null); live = state && await health(state);
|
|
602
|
+
if (compatibleRuntime(live) && live.projectId === project.projectId) {
|
|
603
|
+
await rememberProject(project, { state: { ...state, ...live } });
|
|
604
|
+
return state;
|
|
605
|
+
}
|
|
606
|
+
const log = await fs.readFile(logPath, 'utf8').catch(() => '');
|
|
607
|
+
if (logMentionsDirectoryFailure(log, globalWorkbenchDirectory())) {
|
|
608
|
+
const directory = await defaultDirectoryAvailability();
|
|
609
|
+
if (!directory.overridden && directory.unavailable) throw new MapError('START_FAILED', startFailedMessage({ log, directory }), 503);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
await failStart(logPath);
|
|
613
|
+
}
|
|
614
|
+
export async function request(state, route, { token = state.adminToken, method = 'GET', body } = {}) {
|
|
615
|
+
const encoded = body === undefined ? undefined : JSON.stringify(body);
|
|
616
|
+
const response = await loopbackJSON(new URL(route, state.url), {
|
|
617
|
+
method,
|
|
618
|
+
headers: { Authorization: `Bearer ${token}`, ...(encoded === undefined ? {} : { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(encoded) }) },
|
|
619
|
+
body: encoded,
|
|
620
|
+
timeout: 40000,
|
|
621
|
+
// A valid Map may occupy 16 MiB; control reads also include event metadata.
|
|
622
|
+
// Health probes keep their smaller default budget.
|
|
623
|
+
maxBytes: 32 * 1024 * 1024,
|
|
624
|
+
});
|
|
625
|
+
if (!response) throw new MapError('HTTP_ERROR', 'Workbench control request failed', 503);
|
|
626
|
+
const result = response.value;
|
|
627
|
+
if (!response.ok) throw new MapError(result.error?.code || 'HTTP_ERROR', result.error?.message || `HTTP ${response.status}`, response.status, result.error || {});
|
|
628
|
+
return result;
|
|
629
|
+
}
|
|
630
|
+
export async function stopServer(root) {
|
|
631
|
+
root = await fs.realpath(root);
|
|
632
|
+
const project = await resolveProject(root);
|
|
633
|
+
const sharedState = projectStatePath(project);
|
|
634
|
+
const state = await readJSON(sharedState, null) || await readJSON(statePath(root), null);
|
|
635
|
+
if (!state || !await health(state)) return { stopped: false };
|
|
636
|
+
if (!compatibleRuntime(await health(state))) throw new MapError('LEGACY_SERVICE', 'Export and migrate the old service with its original identity before stopping it');
|
|
637
|
+
await request(state, '/api/stop', { method: 'POST', body: {} });
|
|
638
|
+
const deadline = Date.now() + 12000;
|
|
639
|
+
// A stop acknowledgement is not a released project lock. Do not let the next
|
|
640
|
+
// command race the old process while it flushes writes and closes sockets.
|
|
641
|
+
for (;;) {
|
|
642
|
+
try {
|
|
643
|
+
const current = await readJSON(sharedState, null);
|
|
644
|
+
const lock = await readJSON(projectLockPath(project), null);
|
|
645
|
+
if (current?.instance !== state.instance && lock?.instance !== state.instance) return { stopped: true };
|
|
646
|
+
} catch (error) {
|
|
647
|
+
// Only the acknowledged shutdown may retry delete-pending file contention.
|
|
648
|
+
// An unreadable state/lock is not evidence that this instance released it.
|
|
649
|
+
if (error.code !== 'EBUSY' && !(process.platform === 'win32' && error.code === 'EPERM')) throw error;
|
|
650
|
+
}
|
|
651
|
+
if (Date.now() >= deadline) throw new MapError('STOP_FAILED', 'Workbench has not finished shutting down; project lock preserved', 503);
|
|
652
|
+
await pause(25);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
export function parseInputJSON(text) {
|
|
656
|
+
if (!text.trim()) throw new MapError('INPUT_REQUIRED', 'Provide JSON with --input <file> or --input - and stdin; see this command with --help');
|
|
657
|
+
try { return JSON.parse(text); } catch { throw new MapError('INVALID_JSON', 'Input is not valid JSON; see this command with --help'); }
|
|
658
|
+
}
|
|
659
|
+
async function inputJSON(file) {
|
|
660
|
+
let text = '';
|
|
661
|
+
if (file && file !== '-') text = await fs.readFile(path.resolve(file), 'utf8');
|
|
662
|
+
else for await (const chunk of process.stdin) text += chunk;
|
|
663
|
+
return parseInputJSON(text);
|
|
664
|
+
}
|
|
665
|
+
async function worktreeInputJSON(file, root) {
|
|
666
|
+
if (file && file !== '-') {
|
|
667
|
+
const worktree = await fs.realpath(root);
|
|
668
|
+
const inputFile = await fs.realpath(path.resolve(file));
|
|
669
|
+
const relative = path.relative(worktree, inputFile);
|
|
670
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
671
|
+
throw new MapError('INPUT_OUTSIDE_WORKTREE', 'Use --input - or a JSON file inside this Session worktree; shared temporary files can be overwritten by another Session', 400);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return inputJSON(file);
|
|
675
|
+
}
|
|
676
|
+
export async function connectCloudProject(root, { url, password, browser = false, wait = false, onPending, repositoryLookup = lookupRepository }) {
|
|
677
|
+
const project = await ensureProjectBinding(await resolveProject(root));
|
|
678
|
+
const prior = await readJSON(memoryConfigPath(project), null);
|
|
679
|
+
const origin = new URL(String(url || prior?.url || ''));
|
|
680
|
+
if (origin.username || origin.password || origin.search || origin.hash || origin.pathname !== '/' || !(origin.protocol === 'https:' || origin.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(origin.hostname))) throw new MapError('INVALID_ORIGIN', 'Provide the Cloud HTTPS origin');
|
|
681
|
+
if (!project.github || project.bindingRequired) throw new MapError('BINDING_REQUIRED', 'A GitHub project with a confirmed Main is required');
|
|
682
|
+
const device = new DeviceConnection({ directory: path.join(project.sharedDir, 'interface-v2'), origin: origin.origin, allowLoopback: true });
|
|
683
|
+
const identity = await repositoryLookup(project.github.slug);
|
|
684
|
+
const repository = `https://github.com/${project.github.slug}`;
|
|
685
|
+
const result = browser ? await browserLogin(device, { repository, repositoryId: identity.repositoryId, wait, onPending })
|
|
686
|
+
: await device.connect({ v: 2, id: randomUUID(), type: 'auth.open', payload: { repository, password, clientId: 'local' } }, identity);
|
|
687
|
+
if (browser && !result.connected) return result;
|
|
688
|
+
if (!result.projectId || !result.capabilities?.includes('device-memory')) throw new MapError('UPGRADE_REQUIRED', 'Cloud must support project login for Map access');
|
|
689
|
+
// Preserve legacy configuration for explicit recovery, not silent fallback.
|
|
690
|
+
if (prior?.token) await atomicWrite(memoryConfigPath(project) + '.before-device-login', encode(prior));
|
|
691
|
+
await atomicWrite(memoryConfigPath(project), encode({ url: origin.origin, projectId: result.projectId }));
|
|
692
|
+
return { connected: true, projectId: result.projectId, url: origin.origin };
|
|
693
|
+
}
|
|
694
|
+
async function main(args) {
|
|
695
|
+
if (wantsHelp(args)) {
|
|
696
|
+
const command = args.find(arg => arg && !arg.startsWith('-')) || '';
|
|
697
|
+
console.log(commandHelp(command, options(args.slice(args.indexOf(command) + 1))._));
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
const [command, ...rest] = args, opt = options(rest), root = path.resolve(opt.root || process.cwd());
|
|
701
|
+
if (command === 'sync') {
|
|
702
|
+
const { sessionSync, syncStatus } = await import('./sync.mjs');
|
|
703
|
+
const session = String(opt.session || process.env.CODEX_THREAD_ID || process.env.CLAUDE_SESSION_ID || process.env.CURSOR_SESSION_ID || '');
|
|
704
|
+
const action = opt._[0] || 'status';
|
|
705
|
+
if (action === 'ensure') {
|
|
706
|
+
if (!session) throw new MapError('SESSION_REQUIRED', 'Pass the actual --session before synchronization');
|
|
707
|
+
await syncStatus(root, session);
|
|
708
|
+
return main(['workbench', '--root', root, '--session', session]);
|
|
709
|
+
}
|
|
710
|
+
return sessionSync(root, session, action);
|
|
711
|
+
}
|
|
712
|
+
if (command === 'workbench' && (opt.list || opt._[0] === 'list')) {
|
|
713
|
+
return globalWorkbenchInventory({ currentRoot: root });
|
|
714
|
+
}
|
|
715
|
+
if (command === 'workbench' && opt._[0] === 'bind') {
|
|
716
|
+
if (typeof opt['project-root'] !== 'string') throw new MapError('USAGE', 'workbench bind requires --project-root');
|
|
717
|
+
return bindProject(root, path.resolve(opt['project-root']), { keepLocal: !!opt['keep-local'] });
|
|
718
|
+
}
|
|
719
|
+
if (command === 'workbench' && opt._[0] === 'connect') {
|
|
720
|
+
if (opt.browser && opt.input) throw new MapError('INVALID_ARGUMENT', 'Choose browser authorization or private input');
|
|
721
|
+
const login = opt.input ? await inputJSON(opt.input) : null;
|
|
722
|
+
const result = await connectCloudProject(root, { url: opt.url, password: login?.password, browser: !login, wait: !!opt.wait,
|
|
723
|
+
onPending: opt.wait ? value => process.stderr.write(JSON.stringify(value) + '\n') : undefined });
|
|
724
|
+
if (!result.connected) return result;
|
|
725
|
+
return main(['workbench', '--root', root, '--session', String(opt.session || process.env.CODEX_THREAD_ID || process.env.CLAUDE_SESSION_ID || process.env.CURSOR_SESSION_ID || '')]);
|
|
726
|
+
}
|
|
727
|
+
if (command === 'preferences') return projectPreferences(await resolveProject(root), opt.language);
|
|
728
|
+
if (command === 'memory') {
|
|
729
|
+
const project = await resolveProject(root), session = String(opt.session || process.env.CODEX_THREAD_ID || '');
|
|
730
|
+
if (['sync', 'prepare'].includes(opt._[0])) {
|
|
731
|
+
const { inspectRetiredSync } = await import('./sync.mjs');
|
|
732
|
+
await inspectRetiredSync(project);
|
|
733
|
+
}
|
|
734
|
+
if (opt._[0] === 'configure') {
|
|
735
|
+
const config = await inputJSON(opt.input);
|
|
736
|
+
if (!config.url || !config.token || !config.projectId) throw new MapError('INVALID_MEMORY_CONFIG', 'Provide url, projectId, and token in the private input file');
|
|
737
|
+
await memoryRequest(project, 'main', undefined, config);
|
|
738
|
+
await atomicWrite(memoryConfigPath(project), encode(config));
|
|
739
|
+
return { configured: true, verified: (await memoryStatus(project, session)).current };
|
|
740
|
+
}
|
|
741
|
+
const client = {
|
|
742
|
+
hookEvent: opt['hook-event'] || '',
|
|
743
|
+
eventId: opt['event-id'] || '',
|
|
744
|
+
occurredAt: opt['occurred-at'] || '',
|
|
745
|
+
cursor: opt.cursor ?? null,
|
|
746
|
+
};
|
|
747
|
+
if (opt._[0] === 'sync') return synchronizeMemory(root, session, client);
|
|
748
|
+
if (opt._[0] === 'prepare') return prepareMemory(project, session);
|
|
749
|
+
if (opt._[0] === 'rebase') return rebaseMemory(project, session, { adoptMain: !!opt['adopt-main'] });
|
|
750
|
+
if (opt._[0] === 'publish') return memoryRequest(project, 'publish', await inputJSON(opt.input));
|
|
751
|
+
if (opt._[0] === 'complete') return completeMemory(project, session, await inputJSON(opt.input));
|
|
752
|
+
if (opt._[0] === 'history') {
|
|
753
|
+
const scope = String(opt.scope || (session ? `session:${session}` : 'main'));
|
|
754
|
+
return memoryRequest(project, `history?scope=${encodeURIComponent(scope)}&after=${encodeURIComponent(opt.after || 0)}&limit=${encodeURIComponent(opt.limit || 100)}`);
|
|
755
|
+
}
|
|
756
|
+
if (opt._[0] === 'file') {
|
|
757
|
+
if (!['main', 'session'].includes(opt.scope) || typeof opt.path !== 'string' || !opt.path) {
|
|
758
|
+
throw new MapError('USAGE', 'memory file requires --scope main|session and --path');
|
|
759
|
+
}
|
|
760
|
+
if (opt.scope === 'session' && !session) throw new MapError('USAGE', 'Session file reads require --session');
|
|
761
|
+
const target = opt.scope === 'main' ? 'main' : `sessions/${encodeURIComponent(session)}`;
|
|
762
|
+
const file = opt.path.split('/').map(encodeURIComponent).join('/');
|
|
763
|
+
const version = opt.version ? `?version=${encodeURIComponent(opt.version)}` : '';
|
|
764
|
+
return memoryRequest(project, `filesystem/${target}/${file}${version}`);
|
|
765
|
+
}
|
|
766
|
+
if (opt._[0] === 'restore') return memoryRequest(project, 'restore', await inputJSON(opt.input));
|
|
767
|
+
return memoryStatus(project, session);
|
|
768
|
+
}
|
|
769
|
+
if (command === 'serve') {
|
|
770
|
+
const running = await startServer({ root, port: Number(opt.port ?? 8877), host: opt.host || '127.0.0.1' });
|
|
771
|
+
process.on('SIGTERM', () => running.close()); process.on('SIGINT', () => running.close());
|
|
772
|
+
console.log(JSON.stringify({ url: running.state.url, protocol: 2 })); return;
|
|
773
|
+
}
|
|
774
|
+
if (command === 'map' && opt._[0] === 'main') {
|
|
775
|
+
const project = await ensureProjectBinding(await resolveProject(root));
|
|
776
|
+
if (opt._[1] === 'read') {
|
|
777
|
+
const result = await memoryRequest(project, 'main');
|
|
778
|
+
return { version: result.snapshot?.version || null, doc: result.snapshot?.memory?.map || null };
|
|
779
|
+
}
|
|
780
|
+
if (opt._[1] === 'apply') {
|
|
781
|
+
throw new MapError('FORBIDDEN', 'Only Coordinator can write Main structure; developer Main apply is disabled', 403);
|
|
782
|
+
}
|
|
783
|
+
throw new MapError('INVALID_ARGUMENT', 'Use map main read|apply --input <request.json>');
|
|
784
|
+
}
|
|
785
|
+
if (command === 'workbench' && opt.stop) {
|
|
786
|
+
return stopServer(root);
|
|
787
|
+
}
|
|
788
|
+
if (command === 'workbench' && (opt.migrate || opt._[0] === 'migrate')) {
|
|
789
|
+
return migrateServices(root, opt.retire);
|
|
790
|
+
}
|
|
791
|
+
if (command === 'workbench' && opt['binding-status']) {
|
|
792
|
+
return diagnoseWorkbench(root, String(opt.session || ''));
|
|
793
|
+
}
|
|
794
|
+
if (command === 'workbench' && (opt.diagnose || opt._[0] === 'status')) {
|
|
795
|
+
return diagnoseWorkbench(root, String(opt.session || process.env.CODEX_THREAD_ID || ''));
|
|
796
|
+
}
|
|
797
|
+
if (command === 'workbench' && opt._[0] === 'claude') {
|
|
798
|
+
const project = await resolveProject(root);
|
|
799
|
+
if (!(await bindingStatus(project, String(opt.session || ''))).session.bound) throw new MapError('SESSION_BINDING_REQUIRED', 'Bind the real Claude Session before configuring delivery', 409);
|
|
800
|
+
return request(await ensureServer(root), '/api/claude-runtime', { method: 'POST', body: { sessionId: opt.session, [opt.recover ? 'recovery' : 'config']: await inputJSON(opt.input) } });
|
|
801
|
+
}
|
|
802
|
+
if (command === 'workbench' && (opt['bind-main'] || opt['local-main'])) {
|
|
803
|
+
const project = await saveMainBinding(root, {
|
|
804
|
+
mode: opt['local-main'] ? 'local' : 'remote',
|
|
805
|
+
remote: String(opt.remote || 'origin'),
|
|
806
|
+
branch: String(opt['local-main'] || opt['bind-main']),
|
|
807
|
+
});
|
|
808
|
+
return { saved: true, ...(await bindingStatus(project, String(opt.session || ''))) };
|
|
809
|
+
}
|
|
810
|
+
const project = await ensureProjectBinding(await resolveProject(root));
|
|
811
|
+
if (command === 'workbench' && opt.session && project.bindingRequired) {
|
|
812
|
+
throw new MapError('BINDING_REQUIRED', 'Choose the project main branch before binding this Session', 409, { projectId: project.projectId });
|
|
813
|
+
}
|
|
814
|
+
let sessionId = opt.session || (command !== 'workbench' && (process.env.CODEX_THREAD_ID || process.env.CLAUDE_SESSION_ID || process.env.CURSOR_SESSION_ID));
|
|
815
|
+
const isMaintenance = ['attach-bug', 'update-bug'].includes(command) && !opt.session || command === 'map' && opt._[0] === 'projections' && !opt.session;
|
|
816
|
+
if (sessionId && !isMaintenance) await recordHostAttestedSession(root, String(sessionId));
|
|
817
|
+
if (command !== 'workbench' && !isMaintenance && (!sessionId || !(await bindingStatus(project, sessionId)).session.bound)) {
|
|
818
|
+
const cloud = await readJSON(memoryConfigPath(project), null);
|
|
819
|
+
if (!sessionId || !cloud && !hostAttestedPlatform(sessionId)) throw new MapError('SESSION_BINDING_REQUIRED', 'Connect this project once with workbench --session before Map actions', 409);
|
|
820
|
+
await main(['workbench', '--root', root, '--session', String(sessionId)]);
|
|
821
|
+
}
|
|
822
|
+
const state = opt['workbench-url']
|
|
823
|
+
? await stateForWorkbenchUrl(project, opt['workbench-url'])
|
|
824
|
+
: await ensureServer(root, Number(opt.port ?? 8877));
|
|
825
|
+
if (command === 'workbench') {
|
|
826
|
+
if (opt.role && !sessionId) throw new MapError('SESSION_REQUIRED', '--role requires --session', 400);
|
|
827
|
+
if (opt.role && !['executor', 'coordinator'].includes(opt.role)) throw new MapError('INVALID_ROLE', 'Use executor or coordinator', 400);
|
|
828
|
+
const bindInput = sessionId ? { sessionId, worktreeRoot: root, allowRebind: !!opt.rebind, ...(opt.role ? { role: opt.role } : {}) } : null;
|
|
829
|
+
if (bindInput) await request(state, '/api/session-prepare', { method: 'POST', body: bindInput });
|
|
830
|
+
const cloud = await readJSON(memoryConfigPath(project), null);
|
|
831
|
+
const refreshed = cloud?.url ? { source: null } : await request(state, '/api/project-refresh', { method: 'POST', body: {} });
|
|
832
|
+
const result = opt['workbench-url']
|
|
833
|
+
? { url: String(opt['workbench-url']), projectRoot: state.root }
|
|
834
|
+
: cloud?.url || opt.direct || process.env.CONTEXT_GUARD_NAMED_WORKBENCH === '0'
|
|
835
|
+
? { url: state.url, projectRoot: state.root }
|
|
836
|
+
: await namedWorkbench(state, request, { name: opt.name });
|
|
837
|
+
await verifyWorkbenchUrl(result.url, { projectId: state.projectId, instance: state.instance });
|
|
838
|
+
const registered = bindInput ? await request(state, '/api/session', { method: 'POST', body: { ...bindInput, workbenchUrl: result.url } }) : null;
|
|
839
|
+
const claim = opt['claim-open'] || opt['retry-open'] ? await request(state, '/api/open-claim', { method: 'POST', body: { retry: !!opt['retry-open'] } }) : {};
|
|
840
|
+
const url = new URL(cloud?.projectId ? `/projects/${encodeURIComponent(cloud.projectId)}` : result.url, cloud?.url || result.url);
|
|
841
|
+
if (sessionId) url.searchParams.set('session', String(sessionId));
|
|
842
|
+
const receipt = sessionId ? await diagnoseWorkbench(root, String(sessionId)) : null;
|
|
843
|
+
return { ...result, url: url.href, ...claim, root, projectId: state.projectId, source: refreshed.source, ...runtimeIdentity(), ...(receipt ? { binding: receipt.session, runtime: receipt.runtime, cloudBinding: registered.cloudBinding } : {}) };
|
|
844
|
+
}
|
|
845
|
+
let maintenance = false;
|
|
846
|
+
if (['attach-bug', 'update-bug'].includes(command) && !opt.session || command === 'map' && opt._[0] === 'projections' && !opt.session) {
|
|
847
|
+
sessionId = `maintenance-${process.pid}-${randomUUID()}`;
|
|
848
|
+
maintenance = true;
|
|
849
|
+
await fs.appendFile(path.join(root, '.codex/context/sessions.jsonl'), JSON.stringify({ at: new Date().toISOString(), event: 'maintenance', session_id: sessionId, platform: 'cli' }) + '\n');
|
|
850
|
+
}
|
|
851
|
+
if (!sessionId) throw new MapError('SESSION_REQUIRED', 'Pass the real lifecycle --session ID (or CODEX_THREAD_ID)');
|
|
852
|
+
if (!maintenance && !(await bindingStatus(project, sessionId)).session.bound) {
|
|
853
|
+
throw new MapError('SESSION_BINDING_REQUIRED', 'Ask the user to confirm this Session binding, then run workbench --session before Map actions', 409, { projectId: project.projectId, sessionId });
|
|
854
|
+
}
|
|
855
|
+
const registered = await request(state, '/api/session', { method: 'POST', body: { sessionId, worktreeRoot: root, allowRebind: false } });
|
|
856
|
+
const call = (route, params = {}) => request(state, route, { ...params, token: registered.token });
|
|
857
|
+
const action = command === 'map' ? opt._[0] || 'status' : command;
|
|
858
|
+
if (action === 'coordinator') return call('/api/v2/coordinator-tools', { method: 'POST', body: await inputJSON(opt.input) });
|
|
859
|
+
if (action === 'execution') return call('/api/v2/execution');
|
|
860
|
+
if (action === 'ci') {
|
|
861
|
+
if (opt._[1] === 'context') return call('/api/v2/execution');
|
|
862
|
+
if (opt._[1] === 'exchange') return call('/api/v2/ci', { method: 'POST', body: await worktreeInputJSON(opt.input, root) });
|
|
863
|
+
throw new MapError('INVALID_ARGUMENT', 'Use map ci context|exchange --input <message.json>');
|
|
864
|
+
}
|
|
865
|
+
if (action === 'interrupted') return call('/api/v2/interrupt', { method: 'POST', body: {
|
|
866
|
+
id: opt['event-id'], occurredAt: opt['occurred-at'], reason: 'Local host interrupted the task',
|
|
867
|
+
} });
|
|
868
|
+
if (action === 'exchange') {
|
|
869
|
+
const message = validateMessage(await inputJSON(opt.input));
|
|
870
|
+
if (message.type.startsWith('auth.')) throw new MapError('FORBIDDEN', 'Use the human workbench to authorize the backend', 403);
|
|
871
|
+
if (message.session && message.session.id !== sessionId) throw new MapError('FORBIDDEN', 'Message targets a different Session', 403);
|
|
872
|
+
return call('/api/v2/messages', { method: 'POST', body: message });
|
|
873
|
+
}
|
|
874
|
+
if (action === 'task') {
|
|
875
|
+
if (['plan', 'handoff'].includes(opt._[1])) {
|
|
876
|
+
const input = await worktreeInputJSON(opt.input, root), execution = await call('/api/v2/execution');
|
|
877
|
+
if (execution.active?.mode !== 'reviewed' || !execution.session) throw new MapError('FORBIDDEN', 'An assigned reviewed task is required');
|
|
878
|
+
if (typeof input.operationId !== 'string' || !input.operationId || input.operationId.length > 96) throw new MapError('INVALID_ARGUMENT', 'Provide a stable operationId of at most 96 characters');
|
|
879
|
+
const taskId = execution.active.taskId;
|
|
880
|
+
const identity = createHash('sha256').update(JSON.stringify([taskId, input.operationId])).digest('hex');
|
|
881
|
+
const git = (...args) => {
|
|
882
|
+
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8', windowsHide: true });
|
|
883
|
+
if (result.status !== 0) throw new MapError('GIT_FAILED', 'Cannot verify the worktree revision');
|
|
884
|
+
return result.stdout.trim();
|
|
885
|
+
};
|
|
886
|
+
const sourceSha = git('rev-parse', 'HEAD');
|
|
887
|
+
const exchange = async (type, payload, suffix) => (await call('/api/v2/messages', { method: 'POST', body: validateMessage({ v: 2, id: `${identity}:${suffix}`, type, session: execution.session, payload }) })).data;
|
|
888
|
+
const put = async (kind, content, suffix) => exchange('object.put', { kind, content, ref: `${kind}:${identity}:${suffix}`, baseVersion: '' }, suffix);
|
|
889
|
+
if (opt._[1] === 'plan') {
|
|
890
|
+
if (!input.content || !Array.isArray(input.content.paths) || !input.content.paths.length) throw new MapError('INVALID_ARGUMENT', 'Plan content must include its explicit paths');
|
|
891
|
+
const plan = await put('plan', input.content, 'plan');
|
|
892
|
+
await exchange('task.report', { taskId, stage: 'planReady', data: { planRef: plan.ref, planVersion: plan.version, sourceSha } }, 'report');
|
|
893
|
+
return { taskId, planRef: plan.ref, planVersion: plan.version, sourceSha, state: 'awaiting-plan-review' };
|
|
894
|
+
}
|
|
895
|
+
if (!execution.active.approval) throw new MapError('FORBIDDEN', 'The Coordinator must approve the Plan before handoff');
|
|
896
|
+
if (git('status', '--porcelain')) throw new MapError('UNCOMMITTED_HANDOFF', 'Commit the delivered work; CI must test the exact clean SHA');
|
|
897
|
+
if (!Array.isArray(input.ciTodo?.items) || !input.ciTodo.items.length || !Array.isArray(input.unitTests) || !input.unitTests.length || !Array.isArray(input.experiences)) throw new MapError('INVALID_ARGUMENT', 'Handoff requires CI TODO items, unit-test evidence and an experiences array');
|
|
898
|
+
const ciTodo = await put('ciTodo', input.ciTodo, 'todo'), unitTestRefs = [], experienceRefs = [];
|
|
899
|
+
for (const [index, content] of input.unitTests.entries()) unitTestRefs.push((await put('evidence', content, `test-${index}`)).ref);
|
|
900
|
+
for (const [index, content] of input.experiences.entries()) experienceRefs.push((await put('experience', content, `experience-${index}`)).ref);
|
|
901
|
+
const result = await exchange('task.report', { taskId, stage: 'handoff', data: { sourceSha, ciTodoRef: ciTodo.ref, unitTestRefs, experienceRefs } }, 'report');
|
|
902
|
+
return { ...result, sourceSha };
|
|
903
|
+
}
|
|
904
|
+
const stage = { start: 'started', finish: 'finished' }[opt._[1]];
|
|
905
|
+
if (!stage || !opt._[2]) throw new MapError('INVALID_ARGUMENT', 'Use map task start|finish <delivery-id>; finish requires --summary');
|
|
906
|
+
return call('/api/v2/task-report', { method: 'POST', body: { deliveryId: opt._[2], stage,
|
|
907
|
+
...(stage === 'finished' ? { outcome: opt.outcome || 'success', summary: opt.summary } : {}) } });
|
|
908
|
+
}
|
|
909
|
+
if (['inbox', 'ack', 'watch'].includes(action)) {
|
|
910
|
+
const dir = sessionMemoryDir(project, sessionId);
|
|
911
|
+
const inbox = new AgentInbox(root, sessionId, call, project.kind === 'git' ? { ctx: dir, pendingFile: path.join(dir, 'sync/pending.json'), eventsDir: dir } : {});
|
|
912
|
+
if (action === 'ack') return inbox.acknowledge(opt.receipt);
|
|
913
|
+
if (action === 'watch') return inbox.wait(Number(opt['wait-ms'] ?? 40000));
|
|
914
|
+
return inbox.read({ start: !!opt.start });
|
|
915
|
+
}
|
|
916
|
+
if (action === 'read' || action === 'status') {
|
|
917
|
+
const result = await call('/api/state' + (opt.node ? '?node=' + encodeURIComponent(opt.node) : ''));
|
|
918
|
+
if (result.error || result.recovery) throw new MapError(result.error?.code || 'RECOVERY_REQUIRED', result.error?.message || 'Resolve pending commit recovery before acting', 503, { version: result.version, recovery: result.recovery });
|
|
919
|
+
if (action === 'status') { delete result.doc; delete result.node; }
|
|
920
|
+
return result;
|
|
921
|
+
}
|
|
922
|
+
if (action === 'changes') return call('/api/changes' + (opt.cursor ? '?cursor=' + encodeURIComponent(opt.cursor) : ''));
|
|
923
|
+
if (action === 'operation') return call('/api/operation?id=' + encodeURIComponent(opt.id || ''));
|
|
924
|
+
if (action === 'apply') return call('/api/commit', { method: 'POST', body: await inputJSON(opt.input) });
|
|
925
|
+
if (action === 'reconcile') {
|
|
926
|
+
const input = await inputJSON(opt.input), snapshot = await call('/api/state');
|
|
927
|
+
const reconciliation = buildArchiveReconciliation(snapshot.doc, sessionId, input);
|
|
928
|
+
if (!reconciliation.operations.length) return { committed: true, duplicate: !!reconciliation.key, version: snapshot.version, reconciliation };
|
|
929
|
+
const result = await call('/api/commit', { method: 'POST', body: { operationId: reconciliation.operationId, baseVersion: snapshot.version, operations: reconciliation.operations } });
|
|
930
|
+
return { ...result, reconciliation: { ...reconciliation, operations: reconciliation.operations.map(operation => operation.type) } };
|
|
931
|
+
}
|
|
932
|
+
if (action === 'projections') return call('/api/projections', { method: 'POST', body: { wait: !!opt.wait } });
|
|
933
|
+
if (action === 'record-todo') {
|
|
934
|
+
const input = await inputJSON(opt.input);
|
|
935
|
+
const nodeId = typeof input.node === 'string' ? input.node.trim() : '';
|
|
936
|
+
const signalId = typeof input.signalId === 'string' ? input.signalId.trim() : '';
|
|
937
|
+
const title = typeof input.title === 'string' ? input.title.trim() : '';
|
|
938
|
+
if (!nodeId || !signalId || !title) throw new MapError('INVALID_TODO', 'record-todo needs node, signalId, and title');
|
|
939
|
+
const snapshot = await call('/api/state?node=' + encodeURIComponent(nodeId));
|
|
940
|
+
if (!snapshot.node) throw new MapError('NOT_FOUND', `Node ${nodeId} is missing`, 404);
|
|
941
|
+
const id = typeof input.id === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(input.id)
|
|
942
|
+
? input.id
|
|
943
|
+
: `TD-${createHash('sha256').update(`${sessionId}\0${signalId}`).digest('hex').slice(0, 16)}`;
|
|
944
|
+
const existing = (snapshot.node.todos || []).find(item => item?.id === id || item?.source_signal === signalId);
|
|
945
|
+
if (existing) return { committed: true, duplicate: true, version: snapshot.version, todo: existing };
|
|
946
|
+
const at = typeof input.at === 'string' && input.at ? input.at : new Date().toISOString();
|
|
947
|
+
const todo = {
|
|
948
|
+
id,
|
|
949
|
+
title,
|
|
950
|
+
desc: typeof input.description === 'string' ? input.description.trim() : '',
|
|
951
|
+
status: 'processing',
|
|
952
|
+
sessions: [sessionId],
|
|
953
|
+
target_session: sessionId,
|
|
954
|
+
source_signal: signalId,
|
|
955
|
+
created_at: at,
|
|
956
|
+
updated_at: at,
|
|
957
|
+
};
|
|
958
|
+
const result = await call('/api/commit', {
|
|
959
|
+
method: 'POST',
|
|
960
|
+
body: {
|
|
961
|
+
operationId: `todo:${sessionId}:${signalId}`,
|
|
962
|
+
baseVersion: snapshot.version,
|
|
963
|
+
operations: [{ type: 'update', id: nodeId, fields: { todos: [...(snapshot.node.todos || []), todo] } }],
|
|
964
|
+
},
|
|
965
|
+
});
|
|
966
|
+
return { ...result, todo };
|
|
967
|
+
}
|
|
968
|
+
if (action === 'attach-bug') {
|
|
969
|
+
return attachBugWithRecovery(call, sessionId, await inputJSON(opt.input));
|
|
970
|
+
}
|
|
971
|
+
if (action === 'update-bug') {
|
|
972
|
+
return updateBugWithRecovery(call, sessionId, await inputJSON(opt.input));
|
|
973
|
+
}
|
|
974
|
+
throw new MapError('USAGE', 'Use workbench, attach-bug, update-bug, record-todo, or map status|read|changes|inbox|ack|watch|apply|operation|projections|reconcile');
|
|
975
|
+
}
|
|
976
|
+
const entryPath = value => process.platform === 'win32' ? value.toLowerCase() : value;
|
|
977
|
+
if (process.argv[1] && entryPath(await fs.realpath(process.argv[1]).catch(() => '')) === entryPath(await fs.realpath(ownFile))) {
|
|
978
|
+
try { const result = await main(process.argv.slice(2)); if (result !== undefined) console.log(JSON.stringify(result)); }
|
|
979
|
+
catch (e) { console.log(JSON.stringify({ error: { code: e.code || 'ERROR', message: e.message, ...e.details } })); process.exitCode = 1; }
|
|
980
|
+
}
|