@michelj/context-guard 0.6.2 → 0.6.4
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/package.json +3 -3
- package/prototype/workbench-app.js +10 -4
- package/prototype/workbench-sync.mjs +36 -16
- package/references/cloud-sync-interface.md +7 -0
- package/scripts/shared/protocol-store.mjs +2 -1
- package/scripts/workbench/browser-login.mjs +48 -11
- package/scripts/workbench/claude-runtime.mjs +73 -53
- package/scripts/workbench/listen.mjs +5 -0
- package/scripts/workbench/named-proxy.mjs +11 -2
- package/scripts/workbench/project.mjs +70 -25
- package/scripts/workbench/protocol-device.mjs +34 -6
- package/scripts/workbench/protocol-repository.mjs +28 -10
- package/scripts/workbench/registry.mjs +6 -0
- package/scripts/workbench/server.mjs +41 -4
- package/scripts/workbench/sync-coordinator.mjs +10 -0
- package/scripts/workbench/sync.mjs +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@michelj/context-guard",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"description": "Next-generation collaboration layer for humans and coding agents on Codex, Cursor, and Claude.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -56,8 +56,8 @@
|
|
|
56
56
|
"node": ">=18"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
-
"@michelj/context-guard-core": "https://github.com/Michel-Johnson/Context-Guard-Cloud/releases/download/shared-v1.1.
|
|
60
|
-
"@michelj/context-guard-workbench": "https://github.com/Michel-Johnson/Context-Guard-Cloud/releases/download/shared-v1.1.
|
|
59
|
+
"@michelj/context-guard-core": "https://github.com/Michel-Johnson/Context-Guard-Cloud/releases/download/shared-v1.1.2/michelj-context-guard-core-1.1.2.tgz",
|
|
60
|
+
"@michelj/context-guard-workbench": "https://github.com/Michel-Johnson/Context-Guard-Cloud/releases/download/shared-v1.1.2/michelj-context-guard-workbench-1.1.2.tgz",
|
|
61
61
|
"marked": "15.0.12",
|
|
62
62
|
"playwright": "1.62.1"
|
|
63
63
|
}
|
|
@@ -74,7 +74,7 @@ const I18N = {
|
|
|
74
74
|
remove:"移除", addModule:"接入模块",
|
|
75
75
|
noOpenBugs:"没有 Bug。", unnamedBug:"未命名 Bug", noOpenTodos:"没有 TODO。", unnamedTodo:"未命名 TODO",
|
|
76
76
|
bugSending:"发送中", bugSendFailed:"发送失败", taskQueued:"Cloud 已排队", taskCloudQueued:"等待本地接收", taskBlocked:"等待前序任务完成", taskReceived:"Codex 已收到", taskUncertain:"接收结果待确认", taskInterrupted:"执行中断", taskWaitingReview:"等待 Plan 审核",
|
|
77
|
-
allSessions:"主工作台 · 工作中 Session", globalSessionView:"仅跟随 Main",
|
|
77
|
+
allSessions:"主工作台 · 工作中 Session", globalSessionView:"仅跟随 Main", showOtherSessions:"显示其他会话", hideOtherSessions:"仅显示工作中会话",
|
|
78
78
|
projectOverview:"项目总览",
|
|
79
79
|
scopeRequired:"该 Session 尚未获得当前节点权限。确认后将授权当前节点、所有上级和直接关联节点,共 {n} 个新节点。",
|
|
80
80
|
bugWaiting:"待处理", bugProcessing:"处理中", bugHandoff:"待接手",
|
|
@@ -178,7 +178,7 @@ const I18N = {
|
|
|
178
178
|
remove:"Remove", addModule:"Attach module",
|
|
179
179
|
noOpenBugs:"No bugs.", unnamedBug:"Untitled bug", noOpenTodos:"No TODOs.", unnamedTodo:"Untitled TODO",
|
|
180
180
|
bugSending:"Sending", bugSendFailed:"Send failed", taskQueued:"Queued in Cloud", taskCloudQueued:"Waiting for local host", taskReceived:"Received by Codex", taskUncertain:"Receipt uncertain", taskInterrupted:"Interrupted", taskWaitingReview:"Waiting for Plan review",
|
|
181
|
-
allSessions:"Main workbench · Working sessions", globalSessionView:"Main branch only",
|
|
181
|
+
allSessions:"Main workbench · Working sessions", globalSessionView:"Main branch only", showOtherSessions:"Show other sessions", hideOtherSessions:"Show working sessions only",
|
|
182
182
|
projectOverview:"Projects",
|
|
183
183
|
scopeRequired:"This session cannot access the current node. Confirm to authorize the node, its ancestors, and direct relations ({n} new nodes).",
|
|
184
184
|
bugWaiting:"Waiting", bugProcessing:"In progress", bugHandoff:"Needs handoff",
|
|
@@ -376,6 +376,7 @@ let bugPathReturn = null;
|
|
|
376
376
|
let activeWorkPanelKind = "bug";
|
|
377
377
|
let foldDormant = false;
|
|
378
378
|
let foldMemoryDoc = false;
|
|
379
|
+
let showOtherSessions = false;
|
|
379
380
|
let foldIdea = false;
|
|
380
381
|
let foldTodo = false;
|
|
381
382
|
let foldBug = false;
|
|
@@ -1307,7 +1308,7 @@ function isWorkingSession(meta){
|
|
|
1307
1308
|
function visibleSessionChoices(){
|
|
1308
1309
|
const sessions=normalizeSessions(workbenchSync?.sessions||[]);
|
|
1309
1310
|
const selected=String(workbenchSync?.activeSession||"__all__");
|
|
1310
|
-
return sessions.filter(meta=>isWorkingSession(meta)||(selected!=="__all__"&&sessionIdOf(meta)===selected));
|
|
1311
|
+
return sessions.filter(meta=>showOtherSessions||isWorkingSession(meta)||(selected!=="__all__"&&sessionIdOf(meta)===selected));
|
|
1311
1312
|
}
|
|
1312
1313
|
function browserCurrentSessionId(){
|
|
1313
1314
|
const id=String(new URLSearchParams(location.search).get("session")||"").trim();
|
|
@@ -1469,7 +1470,12 @@ function renderSessionMenu(){
|
|
|
1469
1470
|
<span class="session-option-copy"><span class="session-option-name">${esc(label.primary)}</span>${label.secondary?`<span class="session-option-context">${esc(label.secondary)}</span>`:""}</span>
|
|
1470
1471
|
<span class="session-option-state ${lifecycle.state}">${esc(lifecycle.label)}</span><span class="session-status ${lifecycle.state}" aria-label="${lifecycle.label}"></span>
|
|
1471
1472
|
</button>`;
|
|
1472
|
-
}).join("")
|
|
1473
|
+
}).join("") + `<button type="button" data-show-other-sessions aria-pressed="${showOtherSessions}">${esc(t(showOtherSessions?"hideOtherSessions":"showOtherSessions"))}</button>`;
|
|
1474
|
+
menu.querySelector('[data-show-other-sessions]').onclick = ()=>{
|
|
1475
|
+
showOtherSessions = !showOtherSessions;
|
|
1476
|
+
syncSessionSelect();
|
|
1477
|
+
renderSessionMenu();
|
|
1478
|
+
};
|
|
1473
1479
|
menu.querySelectorAll("[data-session]").forEach(button=>{
|
|
1474
1480
|
button.onclick = async ()=>{
|
|
1475
1481
|
try {
|
|
@@ -117,6 +117,8 @@ export class WorkbenchSync {
|
|
|
117
117
|
this.urlPinned = Boolean(requestedSession) && !this.config?.root?.startsWith('cloud:');
|
|
118
118
|
this.panel = document.createElement('details'); this.panel.id = 'cg-sync'; this.panel.className = 'set-block sync-settings';
|
|
119
119
|
this.panel.innerHTML = '<summary>同步与恢复</summary><p id="cg-sync-status"></p><span id="cg-sync-version" hidden></span><div class="sync-actions"><button id="cg-sync-initialize" hidden>将当前图设为真实地图</button><button id="cg-sync-retry">重试</button><button id="cg-sync-export">导出草稿/旧缓存</button><button id="cg-sync-import">导入并比较</button><button id="cg-sync-reload">保留草稿后读取磁盘</button></div><label>Agent 会话<select id="cg-sync-session"></select></label><input id="cg-sync-file" type="file" accept="application/json" hidden>';
|
|
120
|
+
this.cloudBindingNotice = document.createElement('p'); this.cloudBindingNotice.hidden = true;
|
|
121
|
+
this.cloudBindingNotice.setAttribute('role', 'status'); this.panel.querySelector('#cg-sync-status').after(this.cloudBindingNotice);
|
|
120
122
|
this.repairButton = document.createElement('button'); this.repairButton.id = 'cg-sync-repair'; this.repairButton.hidden = true;
|
|
121
123
|
this.panel.querySelector('.sync-actions').append(this.repairButton);
|
|
122
124
|
if (this.config?.interfaceCapabilities?.deviceLogin && !this.config.root?.startsWith('cloud:')) {
|
|
@@ -764,12 +766,19 @@ export class WorkbenchSync {
|
|
|
764
766
|
this.a.setAccess(active && !unavailableMeta ? this.grants?.[this.activeSession]?.nodes || [] : [], this.activeSession, active, this.activeSession === ALL_SESSIONS, this.project?.main || null);
|
|
765
767
|
}
|
|
766
768
|
renderCloudStatus(status) {
|
|
769
|
+
const bindingLabel = status?.configured && status.status === 'conflict'
|
|
770
|
+
? status.reason === 'session-bound-elsewhere' ? '当前会话绑定旧设备,请新建会话'
|
|
771
|
+
: status.reason === 'binding-conflict' ? '会话绑定冲突,请新建会话' : ''
|
|
772
|
+
: '';
|
|
773
|
+
if (this.cloudBindingNotice) {
|
|
774
|
+
this.cloudBindingNotice.hidden = !bindingLabel; this.cloudBindingNotice.textContent = bindingLabel;
|
|
775
|
+
}
|
|
767
776
|
if (!this.cloudIndicator) return;
|
|
768
777
|
if (!status?.configured) { this.cloudIndicator.hidden = true; return; }
|
|
769
778
|
const value = status.status === 'synced' ? 'synced' : ['conflict', 'error'].includes(status.status) ? status.status : 'syncing';
|
|
770
779
|
this.cloudIndicator.hidden = false; this.cloudIndicator.className = `cloud-sync-status ${value}`;
|
|
771
|
-
const label = value === 'synced' ? '云端已同步' : value === 'syncing' ? '云端同步中' : value === 'conflict' ? '云端同步冲突' : '云端同步失败';
|
|
772
|
-
this.cloudIndicator.setAttribute('aria-label', label); this.cloudIndicator.title = label;
|
|
780
|
+
const label = bindingLabel || (value === 'synced' ? '云端已同步' : value === 'syncing' ? '云端同步中' : value === 'conflict' ? '云端同步冲突' : '云端同步失败');
|
|
781
|
+
this.cloudIndicator.setAttribute('aria-label', label); this.cloudIndicator.title = bindingLabel ? `${label};旧记录与队列保持不变` : label;
|
|
773
782
|
}
|
|
774
783
|
async refreshCloudStatus() {
|
|
775
784
|
if (this.config?.root?.startsWith('cloud:')) { this.renderCloudStatus({ configured: true, status: this.pendingSession ? 'syncing' : 'synced' }); return; }
|
|
@@ -780,18 +789,26 @@ export class WorkbenchSync {
|
|
|
780
789
|
if (this.switchingSession) return false;
|
|
781
790
|
if (this.retrying) await this.retrying;
|
|
782
791
|
if (this.switchingSession) return false;
|
|
783
|
-
|
|
784
|
-
if (this.
|
|
785
|
-
await this.flush();
|
|
786
|
-
if (this.dirty()) { this.setStatus(this.status, '当前视图仍有未保存内容,暂不能切换'); return false; }
|
|
787
|
-
}
|
|
788
|
-
if (this.switchingSession) return false;
|
|
792
|
+
const unavailable = ['unavailable', 'stale', 'closed', 'published', 'expired', 'deleted', 'invalid'];
|
|
793
|
+
if (sessionId !== ALL_SESSIONS && !this.sessions.some(item => item.id === sessionId && !unavailable.includes(String(item.bindingState || '').toLowerCase()) && !unavailable.includes(String(item.status || '').toLowerCase()))) return false;
|
|
789
794
|
const privateCloud = this.config?.root?.startsWith('cloud:') && this.config.root !== 'cloud:overview';
|
|
790
795
|
const nextView = sessionId === ALL_SESSIONS || (!privateCloud && this.project?.kind !== 'git') ? 'main' : `session:${sessionId}`;
|
|
791
|
-
|
|
792
|
-
const
|
|
796
|
+
let previous, previousTree;
|
|
797
|
+
const previousUrl = location.href;
|
|
793
798
|
this.switchingSession = true;
|
|
794
799
|
try {
|
|
800
|
+
// A missing Cloud snapshot must not flush the current view's private draft.
|
|
801
|
+
// This read only checks availability; reload still obtains the final baseline.
|
|
802
|
+
if (privateCloud && nextView.startsWith('session:') && nextView !== this.viewId) {
|
|
803
|
+
const target = await this.call('/api/state', undefined, 'GET', nextView);
|
|
804
|
+
if (target.error || target.recovery || !target.doc?.root) throw Object.assign(new Error(target.error?.message || target.recovery?.message || '地图尚未同步'), { code: target.error?.code || (!target.doc?.root ? 'UNKNOWN_VIEW' : undefined) });
|
|
805
|
+
}
|
|
806
|
+
if (this.dirty()) {
|
|
807
|
+
await this.flush();
|
|
808
|
+
if (this.dirty()) { this.setStatus(this.status, '当前视图仍有未保存内容,暂不能切换'); return false; }
|
|
809
|
+
}
|
|
810
|
+
previous = Object.fromEntries(['activeSession', 'pendingSession', 'manualSession', 'viewId', 'captureKey', 'doc', 'version', 'source', 'baseTree', 'ready', 'initializationRequired', 'pendingRequest', 'inputDraft', 'serverRecovery', 'recoveryBlocked', 'revision', 'cachedDiff', 'sessionUnavailable'].map(key => [key, this[key]]));
|
|
811
|
+
previousTree = copy(this.a.getRoot());
|
|
795
812
|
this.activeSession = sessionId;
|
|
796
813
|
this.sessionUnavailable = false;
|
|
797
814
|
this.pendingSession = '';
|
|
@@ -809,12 +826,15 @@ export class WorkbenchSync {
|
|
|
809
826
|
setTimeout(() => this.refreshAccess().catch(error => this.setStatus('error', error.message)), 0);
|
|
810
827
|
return true;
|
|
811
828
|
} catch (error) {
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
829
|
+
if (previous) {
|
|
830
|
+
Object.assign(this, previous);
|
|
831
|
+
history.replaceState(null, '', previousUrl);
|
|
832
|
+
if (previous.doc) this.a.apply({ ...previous.doc, root: previousTree });
|
|
833
|
+
this.panel.querySelector('#cg-sync-initialize').hidden = !previous.initializationRequired || (previous.viewId === 'main' && previous.source?.status !== 'local-folder');
|
|
834
|
+
this.loadGeneration = (this.loadGeneration || 0) + 1;
|
|
835
|
+
if (!this.sessionUnavailable) this.connect();
|
|
836
|
+
}
|
|
837
|
+
this.setStatus('error', error.code === 'UNKNOWN_VIEW' ? '地图尚未同步;原地图和草稿已保留' : '无法切换地图,原地图和草稿已保留:' + error.message);
|
|
818
838
|
return false;
|
|
819
839
|
} finally { this.switchingSession = false; }
|
|
820
840
|
}
|
|
@@ -61,6 +61,13 @@ Network failure leaves current queues on disk. Reconnect retries with backoff;
|
|
|
61
61
|
conflicts require explicit reconciliation. Do not manufacture a new request ID,
|
|
62
62
|
erase private state or report success merely because a connection is alive.
|
|
63
63
|
|
|
64
|
+
A confirmed Session-binding rejection stops automatic binding retries. When a
|
|
65
|
+
Session belongs to another device, create a new actual conversation in the host
|
|
66
|
+
and connect using that host-issued Session ID. Do not take over the old Session,
|
|
67
|
+
alias/change its ID, discard its failed receipt or move its tasks/queues to the
|
|
68
|
+
new conversation. Older binding-conflict receipts cannot prove which device owns
|
|
69
|
+
the Session; they also require a new host conversation, not blind retries.
|
|
70
|
+
|
|
64
71
|
The private Session service uses authorized `/v1/projects/:project/sessions/`
|
|
65
72
|
reads, changes/events and map writes. Session generations and server authorization
|
|
66
73
|
remain enforced. Credentials never belong in Maps, logs or generated HTML.
|
|
@@ -465,7 +465,8 @@ export class ProtocolStore extends EventEmitter {
|
|
|
465
465
|
previous.sessionId = payload.sessionId;
|
|
466
466
|
return { session: { id: payload.sessionId, generation: previous.generation }, bindingVersion: previous.version };
|
|
467
467
|
}
|
|
468
|
-
if (previous &&
|
|
468
|
+
if (previous && previous.deviceId !== p.deviceId) fail('CONFLICT', 'Session is already bound to another device', { reason: 'session-bound-elsewhere' });
|
|
469
|
+
if (previous && (!options.allowMigration || payload.expectedBindingVersion !== previous.version)) fail('CONFLICT', 'Migration requires the owning device and current binding version', { currentVersion: previous.version });
|
|
469
470
|
if (!previous && payload.expectedBindingVersion) fail('CONFLICT', 'Binding does not exist', { currentVersion: '' });
|
|
470
471
|
const binding = { sessionId: payload.sessionId, deviceId: p.deviceId, agentId: payload.agentId, worktreeId: payload.worktreeId, generation: (previous?.generation || 0) + 1, version: randomUUID() };
|
|
471
472
|
for (const creation of Object.values(state.sessionCreations || {})) {
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
4
|
+
import { performance } from 'node:perf_hooks';
|
|
4
5
|
import { atomicWrite, encode, readJSON, withFileLock, pause } from '../shared/io.mjs';
|
|
5
6
|
import { fail } from '../shared/protocol.mjs';
|
|
6
7
|
|
|
7
|
-
async function request(origin, route, input, fetcher) {
|
|
8
|
+
async function request(origin, route, input, fetcher, timeoutMs = 15000) {
|
|
8
9
|
let response;
|
|
9
10
|
try {
|
|
10
|
-
response = await fetcher(new URL(route, origin), { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(
|
|
11
|
+
response = await fetcher(new URL(route, origin), { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(timeoutMs),
|
|
11
12
|
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) });
|
|
12
|
-
} catch
|
|
13
|
+
} catch (error) {
|
|
14
|
+
const beforeConnection = ['ENOTFOUND', 'ECONNREFUSED', 'UND_ERR_CONNECT_TIMEOUT'].includes(error?.cause?.code || error?.code);
|
|
15
|
+
fail('UNAVAILABLE', route.endsWith('/poll') && !beforeConnection ? 'Authorization claim outcome is unknown; explicitly retry browser authorization' : 'Cloud authorization is unavailable; retry the same connection command',
|
|
16
|
+
{ retryableConnection: route.endsWith('/start') || beforeConnection });
|
|
17
|
+
}
|
|
13
18
|
if ([404, 405, 426].includes(response.status)) fail('UPGRADE_REQUIRED', 'Cloud does not support browser authorization; upgrade Cloud or use the explicit private-input login');
|
|
14
19
|
if (!response.headers.get('content-type')?.includes('application/json')) fail('UNAVAILABLE', 'Cloud returned an invalid authorization reply');
|
|
15
20
|
let result;
|
|
@@ -18,24 +23,47 @@ async function request(origin, route, input, fetcher) {
|
|
|
18
23
|
for await (const chunk of response.body) { size += chunk.length; if (size > 16384) fail('TOO_LARGE', 'Authorization reply is too large'); chunks.push(chunk); }
|
|
19
24
|
result = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
20
25
|
} catch (error) { if (error.code === 'TOO_LARGE') throw error; fail('UNAVAILABLE', 'Cloud authorization reply was interrupted'); }
|
|
21
|
-
if (!response.ok || result?.ok !== true) fail(result?.error?.code || 'UNAVAILABLE', result?.error?.message || 'Cloud authorization failed'
|
|
26
|
+
if (!response.ok || result?.ok !== true) fail(result?.error?.code || 'UNAVAILABLE', result?.error?.message || 'Cloud authorization failed',
|
|
27
|
+
{ retryableConnection: [502, 503, 504].includes(response.status) && result?.error?.code === 'UNAVAILABLE' && result.error.retryable === true });
|
|
22
28
|
if (!result.data || typeof result.data !== 'object') fail('UNAVAILABLE', 'Cloud returned an invalid authorization reply');
|
|
23
29
|
return { data: result.data, credential: response.headers.get('x-context-guard-credential') };
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
// The model sees only the verification URL/code. The backend keeps the claim
|
|
27
33
|
// secret and final credential private, including across a stopped CLI invocation.
|
|
28
|
-
export async function browserLogin(device, { repository, repositoryId, wait = false, onPending = () => {}, fetcher = fetch, label = os.hostname() }) {
|
|
34
|
+
export async function browserLogin(device, { repository, repositoryId, wait = false, onPending = () => {}, fetcher = fetch, label = os.hostname(), clock = () => performance.now(), sleep = pause }) {
|
|
29
35
|
const origin = new URL(device.origin);
|
|
30
36
|
if (origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash || origin.protocol !== 'https:' && !(device.allowLoopback && origin.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(origin.hostname))) fail('FORBIDDEN', 'Cloud authorization requires an HTTPS origin');
|
|
31
37
|
const file = path.join(device.directory, 'browser-login.json');
|
|
38
|
+
let deadline = clock() + 600000, grant;
|
|
39
|
+
const checkDeadline = () => {
|
|
40
|
+
if (wait && clock() >= deadline) fail('UNAUTHORIZED', 'Browser authorization expired; retry the connection command', { reason: 'authorization-wait-expired' });
|
|
41
|
+
};
|
|
42
|
+
const authorize = async (route, input) => {
|
|
43
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
44
|
+
checkDeadline();
|
|
45
|
+
try {
|
|
46
|
+
const result = await request(device.origin, route, input, fetcher, wait ? Math.max(1, Math.min(15000, Math.ceil(deadline - clock()))) : 15000);
|
|
47
|
+
checkDeadline();
|
|
48
|
+
return result;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
checkDeadline();
|
|
51
|
+
if (attempt === 2 || error.code !== 'UNAVAILABLE' || error.details?.retryableConnection !== true) throw error;
|
|
52
|
+
await sleep(wait ? Math.min(250 * 2 ** attempt, Math.max(1, deadline - clock())) : 250 * 2 ** attempt);
|
|
53
|
+
checkDeadline();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
32
57
|
let first = true;
|
|
33
58
|
for (;;) {
|
|
59
|
+
checkDeadline();
|
|
34
60
|
const outcome = await withFileLock(file + '.lock', async () => {
|
|
61
|
+
checkDeadline();
|
|
35
62
|
const connected = await readJSON(device.file, null);
|
|
36
63
|
if (connected?.origin === device.origin && connected.repositoryId === repositoryId && connected.credential) {
|
|
37
64
|
try {
|
|
38
65
|
await device.transmit({ v: 2, id: randomUUID(), type: 'sync.heartbeat', payload: { sessions: [] } });
|
|
66
|
+
checkDeadline();
|
|
39
67
|
const { credential: _secret, origin: _origin, ...data } = connected;
|
|
40
68
|
return { connected: true, ...data };
|
|
41
69
|
} catch (error) {
|
|
@@ -43,25 +71,33 @@ export async function browserLogin(device, { repository, repositoryId, wait = fa
|
|
|
43
71
|
}
|
|
44
72
|
}
|
|
45
73
|
let pending = await readJSON(file, null);
|
|
46
|
-
|
|
74
|
+
checkDeadline();
|
|
75
|
+
if (grant && ['deviceCode', 'verificationUrl', 'userCode', 'expiresAt', 'origin', 'repository', 'repositoryId'].some(key => pending?.[key] !== grant[key])) fail('CONFLICT', 'Browser authorization changed; retry the connection command');
|
|
76
|
+
if (!pending?.deviceCode || pending.origin !== device.origin || pending.repository !== repository || pending.repositoryId !== repositoryId || !Number.isFinite(Date.parse(pending.expiresAt)) || !grant && Date.parse(pending.expiresAt) <= Date.now()) {
|
|
47
77
|
pending = { origin: device.origin, repository, repositoryId, deviceCode: randomBytes(32).toString('base64url'), label: String(label).slice(0, 80),
|
|
48
78
|
expiresAt: new Date(Date.now() + 600000).toISOString() };
|
|
49
79
|
await atomicWrite(file, encode(pending));
|
|
50
80
|
}
|
|
51
81
|
if (!pending.verificationUrl) {
|
|
52
82
|
const identity = await device.identity();
|
|
53
|
-
const { data } = await
|
|
83
|
+
const { data } = await authorize('/api/auth/device/start', { repository, clientId: identity.clientId, deviceCode: pending.deviceCode, label: pending.label });
|
|
54
84
|
const verification = new URL(data.verificationPath, device.origin);
|
|
55
85
|
if (verification.origin !== device.origin || verification.pathname !== '/connect' || !/^[A-F0-9]{4}-[A-F0-9]{4}$/.test(data.userCode) || verification.searchParams.get('code') !== data.userCode || !Number.isFinite(Date.parse(data.expiresAt)) || !Number.isInteger(data.expiresIn) || data.expiresIn <= 0 || data.expiresIn > 600) fail('UNAVAILABLE', 'Cloud returned an invalid authorization link');
|
|
56
86
|
pending = { ...pending, verificationUrl: verification.href, userCode: data.userCode, expiresAt: new Date(Date.now() + data.expiresIn * 1000).toISOString() };
|
|
57
87
|
await atomicWrite(file, encode(pending));
|
|
58
88
|
}
|
|
59
89
|
const safe = { connected: false, authorizationRequired: true, verificationUrl: pending.verificationUrl, userCode: pending.userCode, expiresAt: pending.expiresAt };
|
|
90
|
+
if (wait && !grant) {
|
|
91
|
+
// A displayed grant and this invocation's budget never renew in a wait loop.
|
|
92
|
+
deadline = Math.min(deadline, clock() + Math.max(0, Date.parse(pending.expiresAt) - Date.now()));
|
|
93
|
+
grant = { ...pending };
|
|
94
|
+
}
|
|
95
|
+
checkDeadline();
|
|
60
96
|
if (first) await onPending(safe);
|
|
61
97
|
let result;
|
|
62
|
-
try { result = await
|
|
98
|
+
try { result = await authorize('/api/auth/device/poll', { deviceCode: pending.deviceCode }); }
|
|
63
99
|
catch (error) {
|
|
64
|
-
if (['UNAUTHORIZED', 'FORBIDDEN'].includes(error.code)) await atomicWrite(file, encode({ status: error.code }));
|
|
100
|
+
if (['UNAUTHORIZED', 'FORBIDDEN'].includes(error.code) && error.details?.reason !== 'authorization-wait-expired') await atomicWrite(file, encode({ status: error.code }));
|
|
65
101
|
throw error;
|
|
66
102
|
}
|
|
67
103
|
if (result.data.status === 'pending') return safe;
|
|
@@ -71,8 +107,9 @@ export async function browserLogin(device, { repository, repositoryId, wait = fa
|
|
|
71
107
|
return { connected: true, ...result.data };
|
|
72
108
|
});
|
|
73
109
|
if (outcome.connected || !wait) return outcome;
|
|
74
|
-
|
|
110
|
+
checkDeadline();
|
|
75
111
|
first = false;
|
|
76
|
-
await
|
|
112
|
+
await sleep(Math.min(5000, Math.max(1, deadline - clock())));
|
|
113
|
+
checkDeadline();
|
|
77
114
|
}
|
|
78
115
|
}
|
|
@@ -292,6 +292,68 @@ export class ClaudeRuntime {
|
|
|
292
292
|
}
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
// Monitor an already-created native process. The worker calls this immediately
|
|
296
|
+
// after spawn; it does not wait for a model-specific readiness handshake.
|
|
297
|
+
export async function runClaudeTurn(child, { config, sessionId, message, logFile, onStarted, onFinished }) {
|
|
298
|
+
const exit = new Promise((resolve, reject) => { child.once('close', resolve); child.once('error', reject); });
|
|
299
|
+
exit.catch(() => {}); // A spawn failure may precede opening the private log.
|
|
300
|
+
let buffer = '', bytes = 0, result = null, initialized = false, stopReason = null;
|
|
301
|
+
const decoder = new StringDecoder('utf8');
|
|
302
|
+
const output = await fs.open(logFile, 'a', 0o600);
|
|
303
|
+
let writes = Promise.resolve();
|
|
304
|
+
const idleMs = config.timeoutMs || 600000;
|
|
305
|
+
let idleTimer, killTimer;
|
|
306
|
+
const terminate = (reason = 'CLAUDE_TIMEOUT_OR_INTERRUPTED') => {
|
|
307
|
+
if (stopReason) return;
|
|
308
|
+
stopReason = reason; child.kill('SIGTERM');
|
|
309
|
+
killTimer = setTimeout(() => child.kill('SIGKILL'), 5000);
|
|
310
|
+
};
|
|
311
|
+
const active = () => { clearTimeout(idleTimer); idleTimer = setTimeout(terminate, idleMs); };
|
|
312
|
+
active();
|
|
313
|
+
// Activity keeps the idle timer alive, but cannot extend a native turn
|
|
314
|
+
// indefinitely. A stopped turn stays recoverable in the same Session.
|
|
315
|
+
const hardTimer = setTimeout(() => terminate('CLAUDE_TURN_LIMIT'), Math.max(idleMs, config.maxTurnMs || 1800000));
|
|
316
|
+
const interrupted = () => terminate();
|
|
317
|
+
const stdinError = () => {};
|
|
318
|
+
const receive = chunk => {
|
|
319
|
+
bytes += chunk.length;
|
|
320
|
+
if (bytes > (config.outputLimitBytes || 64 * 1024 * 1024)) { terminate('CLAUDE_OUTPUT_LIMIT'); return; }
|
|
321
|
+
active();
|
|
322
|
+
writes = writes.then(() => output.write(chunk));
|
|
323
|
+
buffer += decoder.write(chunk);
|
|
324
|
+
for (;;) {
|
|
325
|
+
const index = buffer.indexOf('\n'); if (index < 0) break;
|
|
326
|
+
const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
|
|
327
|
+
try {
|
|
328
|
+
const event = JSON.parse(line);
|
|
329
|
+
if (event.session_id && event.session_id !== sessionId) { terminate('CLAUDE_SESSION_MISMATCH'); break; }
|
|
330
|
+
if (event.type === 'system' && event.subtype === 'init') initialized = true;
|
|
331
|
+
if (event.type === 'result') result = event;
|
|
332
|
+
} catch { /* Native diagnostic text is not a protocol receipt. */ }
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
process.once('SIGTERM', interrupted); process.once('SIGINT', interrupted);
|
|
336
|
+
child.stdout.on('data', receive);
|
|
337
|
+
child.stderr.on('data', active); // Activity only; never publish provider stderr or credentials.
|
|
338
|
+
child.stdin.on('error', stdinError);
|
|
339
|
+
try {
|
|
340
|
+
child.stdin.end(message);
|
|
341
|
+
await onStarted?.(); // Same point as the worker's original running update.
|
|
342
|
+
const code = await exit;
|
|
343
|
+
await writes; await output.sync();
|
|
344
|
+
const outcome = { state: stopReason || !result ? 'interrupted' : code === 0 && !result.is_error ? 'finished' : 'failed',
|
|
345
|
+
error: stopReason || (!result ? 'CLAUDE_NO_RESULT' : result.is_error ? 'CLAUDE_FAILED' : null), initialized };
|
|
346
|
+
await onFinished?.(outcome); // Preserve persistence before timer/log cleanup.
|
|
347
|
+
return outcome;
|
|
348
|
+
} finally {
|
|
349
|
+
clearTimeout(idleTimer); clearTimeout(hardTimer); clearTimeout(killTimer);
|
|
350
|
+
process.removeListener('SIGTERM', interrupted); process.removeListener('SIGINT', interrupted);
|
|
351
|
+
child.stdout.removeListener('data', receive); child.stderr.removeListener('data', active);
|
|
352
|
+
child.stdin.removeListener('error', stdinError);
|
|
353
|
+
await output.close();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
295
357
|
async function runWorker(file, jobFile) {
|
|
296
358
|
const session = await readJSON(file), config = session.config;
|
|
297
359
|
const job = await readJSON(jobFile);
|
|
@@ -303,66 +365,24 @@ async function runWorker(file, jobFile) {
|
|
|
303
365
|
// Written before spawning Claude. A crash after this point is uncertain, not
|
|
304
366
|
// permission to submit the same prompt again.
|
|
305
367
|
await update({ workerPid: process.pid, state: 'dispatching' });
|
|
306
|
-
let child, stopReason = null;
|
|
307
368
|
try {
|
|
308
369
|
const credentials = await readJSON(config.environmentFile);
|
|
309
370
|
if (Object.keys(credentials).some(key => !providerKeys.has(key)) || Object.values(credentials).some(value => typeof value !== 'string')) fail('INVALID_ENVIRONMENT', 'Only provider settings are accepted in the private environment file');
|
|
310
371
|
const env = await claudeEnvironment(config, credentials);
|
|
311
|
-
child = spawn(config.command, claudeArguments(config, { sessionId: session.sessionId, resume: job.resume }), {
|
|
372
|
+
const child = spawn(config.command, claudeArguments(config, { sessionId: session.sessionId, resume: job.resume }), {
|
|
312
373
|
cwd: config.root, env, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
313
374
|
});
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
stopReason = reason; child.kill('SIGTERM');
|
|
325
|
-
killTimer = setTimeout(() => child.kill('SIGKILL'), 5000);
|
|
326
|
-
};
|
|
327
|
-
const active = () => { clearTimeout(idleTimer); idleTimer = setTimeout(terminate, idleMs); };
|
|
328
|
-
active();
|
|
329
|
-
// Activity keeps the idle timer alive, but cannot extend a native turn
|
|
330
|
-
// indefinitely. A stopped turn stays recoverable in the same Session.
|
|
331
|
-
const hardTimer = setTimeout(() => terminate('CLAUDE_TURN_LIMIT'), Math.max(idleMs, config.maxTurnMs || 1800000));
|
|
332
|
-
const interrupted = () => terminate();
|
|
333
|
-
process.once('SIGTERM', interrupted); process.once('SIGINT', interrupted);
|
|
334
|
-
child.stdout.on('data', chunk => {
|
|
335
|
-
bytes += chunk.length;
|
|
336
|
-
if (bytes > (config.outputLimitBytes || 64 * 1024 * 1024)) { terminate('CLAUDE_OUTPUT_LIMIT'); return; }
|
|
337
|
-
active();
|
|
338
|
-
writes = writes.then(() => output.write(chunk));
|
|
339
|
-
buffer += decoder.write(chunk);
|
|
340
|
-
for (;;) {
|
|
341
|
-
const index = buffer.indexOf('\n'); if (index < 0) break;
|
|
342
|
-
const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
|
|
343
|
-
try {
|
|
344
|
-
const event = JSON.parse(line);
|
|
345
|
-
if (event.session_id && event.session_id !== session.sessionId) { terminate('CLAUDE_SESSION_MISMATCH'); break; }
|
|
346
|
-
if (event.type === 'system' && event.subtype === 'init') initialized = true;
|
|
347
|
-
if (event.type === 'result') result = event;
|
|
348
|
-
} catch { /* Native diagnostic text is not a protocol receipt. */ }
|
|
349
|
-
}
|
|
375
|
+
await runClaudeTurn(child, { config, sessionId: session.sessionId, message: job.message,
|
|
376
|
+
logFile: jobFile + '.jsonl', onStarted: () => update({ state: 'running', childPid: child.pid }),
|
|
377
|
+
onFinished: async ({ initialized, ...outcome }) => {
|
|
378
|
+
await update(outcome);
|
|
379
|
+
await withFileLock(file + '.lock', async () => {
|
|
380
|
+
const latest = await readJSON(file);
|
|
381
|
+
await atomicWrite(file, encode({ ...latest, initialized: latest.initialized || initialized,
|
|
382
|
+
...(['finished', 'failed'].includes(job.state) ? { active: null } : {}), updatedAt: job.updatedAt }));
|
|
383
|
+
});
|
|
384
|
+
},
|
|
350
385
|
});
|
|
351
|
-
child.stderr.on('data', active); // Activity only; never publish provider stderr or credentials.
|
|
352
|
-
child.stdin.on('error', () => {});
|
|
353
|
-
child.stdin.end(job.message);
|
|
354
|
-
try {
|
|
355
|
-
await update({ state: 'running', childPid: child.pid });
|
|
356
|
-
const code = await exit;
|
|
357
|
-
await writes; await output.sync();
|
|
358
|
-
await update({ state: stopReason || !result ? 'interrupted' : code === 0 && !result.is_error ? 'finished' : 'failed',
|
|
359
|
-
error: stopReason || (!result ? 'CLAUDE_NO_RESULT' : result.is_error ? 'CLAUDE_FAILED' : null) });
|
|
360
|
-
await withFileLock(file + '.lock', async () => {
|
|
361
|
-
const latest = await readJSON(file);
|
|
362
|
-
await atomicWrite(file, encode({ ...latest, initialized: latest.initialized || initialized,
|
|
363
|
-
...(['finished', 'failed'].includes(job.state) ? { active: null } : {}), updatedAt: job.updatedAt }));
|
|
364
|
-
});
|
|
365
|
-
} finally { clearTimeout(idleTimer); clearTimeout(hardTimer); clearTimeout(killTimer); await output.close(); }
|
|
366
386
|
} catch (error) { await update({ state: 'interrupted', error: String(error.code || 'CLAUDE_LAUNCH_FAILED').slice(0, 100) }); }
|
|
367
387
|
}
|
|
368
388
|
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export function canRetryWorkbenchListen(error, port, attempt, platform = process.platform) {
|
|
2
|
+
if (port === 0 || attempt >= 20) return false;
|
|
3
|
+
return error?.code === 'EADDRINUSE' || platform === 'win32' && error?.code === 'EACCES'
|
|
4
|
+
&& error.syscall === 'listen' && error.address === '127.0.0.1' && error.port === port + attempt;
|
|
5
|
+
}
|
|
@@ -6,6 +6,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
6
6
|
import { atomicWrite, encode, readJSON } from '../shared/io.mjs';
|
|
7
7
|
import { RouteStore } from './portless-routes.mjs';
|
|
8
8
|
import { compatibleRuntime } from './runtime.mjs';
|
|
9
|
+
import { canRetryWorkbenchListen } from './listen.mjs';
|
|
9
10
|
import { DeviceHeartbeat } from './device-heartbeat.mjs';
|
|
10
11
|
|
|
11
12
|
const secret = () => randomBytes(32).toString('base64url');
|
|
@@ -81,8 +82,16 @@ export async function startNamedProxy({ dir, port = 1355 } = {}) {
|
|
|
81
82
|
server.on('upgrade', (_req, socket) => socket.destroy()); // Workbench uses SSE, not WebSockets.
|
|
82
83
|
server.requestTimeout = 15000; server.headersTimeout = 10000;
|
|
83
84
|
for (let attempt = 0; ; attempt++) {
|
|
84
|
-
try {
|
|
85
|
-
|
|
85
|
+
try {
|
|
86
|
+
await new Promise((resolve, reject) => {
|
|
87
|
+
const onListening = () => { server.off('error', onError); resolve(); };
|
|
88
|
+
const onError = error => { server.off('error', onError); server.off('listening', onListening); reject(error); };
|
|
89
|
+
server.once('error', onError);
|
|
90
|
+
try { server.listen(port ? port + attempt : 0, '127.0.0.1', onListening); }
|
|
91
|
+
catch (error) { onError(error); }
|
|
92
|
+
});
|
|
93
|
+
break;
|
|
94
|
+
} catch (error) { if (!canRetryWorkbenchListen(error, port, attempt)) throw error; }
|
|
86
95
|
}
|
|
87
96
|
base = `http://127.0.0.1:${server.address().port}`;
|
|
88
97
|
const state = { version: 1, runtimeSchema: PROXY_RUNTIME_SCHEMA, instance, pid: process.pid, base, adminToken };
|
|
@@ -11,7 +11,7 @@ const digest = value => createHash('sha256').update(String(value)).digest('hex')
|
|
|
11
11
|
export const nonInteractiveGitEnvironment = (parent = process.env) => ({ ...parent, GIT_TERMINAL_PROMPT: '0' });
|
|
12
12
|
export const optionalGitMiss = error => Number.isInteger(error?.code) && error.code !== 0 && !error.killed && error.signal == null;
|
|
13
13
|
|
|
14
|
-
async function git(root, args, { optional = false } = {}) {
|
|
14
|
+
async function git(root, args, { optional = false, rawOutput = false } = {}) {
|
|
15
15
|
try {
|
|
16
16
|
const { stdout } = await execFileAsync('git', args, {
|
|
17
17
|
cwd: root,
|
|
@@ -21,7 +21,7 @@ async function git(root, args, { optional = false } = {}) {
|
|
|
21
21
|
timeout: 10000,
|
|
22
22
|
maxBuffer: 16 * 1024 * 1024,
|
|
23
23
|
});
|
|
24
|
-
return stdout.trim();
|
|
24
|
+
return rawOutput ? stdout : stdout.trim();
|
|
25
25
|
} catch (error) {
|
|
26
26
|
if (optional && optionalGitMiss(error)) return '';
|
|
27
27
|
throw error;
|
|
@@ -62,14 +62,58 @@ async function defaultBranch(root, remote = 'origin') {
|
|
|
62
62
|
return '';
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
async function
|
|
65
|
+
async function physicalGitDirectories(root) {
|
|
66
|
+
let output;
|
|
67
|
+
try {
|
|
68
|
+
output = await git(root, ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir', '--git-dir'], { rawOutput: true });
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (!optionalGitMiss(error)) throw error;
|
|
71
|
+
// Supported Git reports these misses without any directory records. Other
|
|
72
|
+
// failures may be older-option incompatibilities; keep the legacy path.
|
|
73
|
+
if (/fatal: (?:not a git repository|this operation must be run in a work tree)/.test(String(error.stderr || ''))) return null;
|
|
74
|
+
}
|
|
75
|
+
if (output !== undefined) {
|
|
76
|
+
const records = (output.endsWith('\n') ? output.slice(0, -1) : output).split('\n');
|
|
77
|
+
if (records.length === 3 && records.every(value => value && !value.includes('\r') && path.isAbsolute(value))) {
|
|
78
|
+
return { topLevel: records[0], commonDir: records[1], gitDir: records[2] };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const topLevel = await git(root, ['rev-parse', '--show-toplevel'], { optional: true });
|
|
82
|
+
if (!topLevel) return null;
|
|
83
|
+
const worktreeRoot = await fs.realpath(topLevel);
|
|
84
|
+
let commonDir = await git(worktreeRoot, ['rev-parse', '--path-format=absolute', '--git-common-dir'], { optional: true });
|
|
85
|
+
if (!commonDir) {
|
|
86
|
+
const relative = await git(worktreeRoot, ['rev-parse', '--git-common-dir']);
|
|
87
|
+
commonDir = path.resolve(worktreeRoot, relative);
|
|
88
|
+
}
|
|
89
|
+
return { topLevel, commonDir };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function commitIds(root, mainRef) {
|
|
93
|
+
if (!mainRef) return [await git(root, ['rev-parse', 'HEAD'], { optional: true }), ''];
|
|
94
|
+
try {
|
|
95
|
+
const output = await git(root, ['rev-parse', '--revs-only', '--end-of-options', 'HEAD', `${mainRef}^{commit}`]);
|
|
96
|
+
const records = output.split(/\r?\n/);
|
|
97
|
+
if (records.length === 2 && records.every(value => /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value))
|
|
98
|
+
&& records[0].length === records[1].length) return records;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (!optionalGitMiss(error)) throw error;
|
|
101
|
+
}
|
|
102
|
+
// Missing refs, older options and ambiguous output retain the independent
|
|
103
|
+
// fresh reads; process/system failures must not silently enter this fallback.
|
|
104
|
+
return Promise.all([
|
|
105
|
+
git(root, ['rev-parse', 'HEAD'], { optional: true }),
|
|
106
|
+
git(root, ['rev-parse', '--verify', '--quiet', `${mainRef}^{commit}`], { optional: true }),
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function worktreeMetadata(root, main = null, physicalGitDir) {
|
|
66
111
|
const mainBranch = main?.branch || '';
|
|
67
112
|
const mainRef = main?.ref || '';
|
|
68
|
-
const [head, currentBranch, rawGitDir
|
|
69
|
-
|
|
113
|
+
const [[head, mainSha], currentBranch, rawGitDir] = await Promise.all([
|
|
114
|
+
commitIds(root, mainRef),
|
|
70
115
|
git(root, ['branch', '--show-current'], { optional: true }),
|
|
71
|
-
git(root, ['rev-parse', '--path-format=absolute', '--git-dir'], { optional: true }),
|
|
72
|
-
mainRef ? git(root, ['rev-parse', '--verify', '--quiet', `${mainRef}^{commit}`], { optional: true }) : '',
|
|
116
|
+
physicalGitDir === undefined ? git(root, ['rev-parse', '--path-format=absolute', '--git-dir'], { optional: true }) : physicalGitDir,
|
|
73
117
|
]);
|
|
74
118
|
const gitDir = rawGitDir ? await fs.realpath(rawGitDir).catch(() => path.resolve(root, rawGitDir)) : '';
|
|
75
119
|
return { branch: currentBranch, head, gitDir, mainBranch, mainRef, mainSha };
|
|
@@ -91,8 +135,8 @@ function mainTarget({ mode = 'remote', remote = 'origin', branch } = {}) {
|
|
|
91
135
|
|
|
92
136
|
export async function resolveProject(openedRoot) {
|
|
93
137
|
const requestedRoot = await fs.realpath(path.resolve(openedRoot));
|
|
94
|
-
const
|
|
95
|
-
if (!
|
|
138
|
+
const directories = await physicalGitDirectories(requestedRoot);
|
|
139
|
+
if (!directories) {
|
|
96
140
|
const projectId = `folder-${digest(requestedRoot).slice(0, 20)}`;
|
|
97
141
|
return {
|
|
98
142
|
projectId,
|
|
@@ -111,27 +155,28 @@ export async function resolveProject(openedRoot) {
|
|
|
111
155
|
branch: '', head: '', gitDir: '', mainBranch: '', mainRef: '', mainSha: '',
|
|
112
156
|
};
|
|
113
157
|
}
|
|
114
|
-
const worktreeRoot = await fs.realpath(topLevel);
|
|
115
|
-
|
|
116
|
-
if (!commonDir) {
|
|
117
|
-
const relative = await git(worktreeRoot, ['rev-parse', '--git-common-dir']);
|
|
118
|
-
commonDir = path.resolve(worktreeRoot, relative);
|
|
119
|
-
}
|
|
120
|
-
commonDir = await fs.realpath(commonDir);
|
|
158
|
+
const worktreeRoot = await fs.realpath(directories.topLevel);
|
|
159
|
+
const commonDir = await fs.realpath(directories.commonDir);
|
|
121
160
|
const projectId = `git-${digest(commonDir).slice(0, 20)}`;
|
|
122
161
|
const sharedDir = path.join(commonDir, 'context-guard');
|
|
123
162
|
const stored = await readJSON(path.join(sharedDir, 'project-binding.json'), null);
|
|
124
163
|
if (stored && (stored.projectId !== projectId || !stored.main?.branch)) throw new Error('Invalid project binding; repair it explicitly, do not recreate it');
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
? await
|
|
132
|
-
: '';
|
|
164
|
+
let selected;
|
|
165
|
+
if (stored) {
|
|
166
|
+
selected = mainTarget(stored.main);
|
|
167
|
+
} else {
|
|
168
|
+
const originUrl = await git(worktreeRoot, ['config', '--get', 'remote.origin.url'], { optional: true });
|
|
169
|
+
const originGithub = githubRepository(originUrl);
|
|
170
|
+
const automaticBranch = originGithub ? await defaultBranch(worktreeRoot) : '';
|
|
171
|
+
selected = automaticBranch ? mainTarget({ remote: 'origin', branch: automaticBranch }) : null;
|
|
172
|
+
}
|
|
173
|
+
const [remote, metadata] = await Promise.all([
|
|
174
|
+
selected?.mode === 'remote'
|
|
175
|
+
? git(worktreeRoot, ['config', '--get', `remote.${selected.remote}.url`], { optional: true })
|
|
176
|
+
: '',
|
|
177
|
+
worktreeMetadata(worktreeRoot, selected, directories.gitDir),
|
|
178
|
+
]);
|
|
133
179
|
const github = githubRepository(remote);
|
|
134
|
-
const metadata = await worktreeMetadata(worktreeRoot, selected);
|
|
135
180
|
return {
|
|
136
181
|
projectId,
|
|
137
182
|
kind: 'git',
|
|
@@ -9,6 +9,13 @@ import { sendMessage, ProjectMessagePump } from './protocol-client.mjs';
|
|
|
9
9
|
|
|
10
10
|
const readTypes = new Set(['sync.heartbeat', 'sync.read', 'workbench.read', 'object.read', 'blob.get']);
|
|
11
11
|
const messageScope = message => message.session?.id || message.payload.sessionId || 'project';
|
|
12
|
+
function rejectedBindingReason(record, sessionId) {
|
|
13
|
+
if (record?.state !== 'rejected' || record.message?.type !== 'session.bind' || record.message.payload?.sessionId !== sessionId || record.error?.code !== 'CONFLICT') return '';
|
|
14
|
+
if (record.error.details?.reason === 'session-bound-elsewhere') return 'session-bound-elsewhere';
|
|
15
|
+
// This older rejection also covered same-device migration/version conflicts.
|
|
16
|
+
if (record.error.message === 'Migration requires the owning device and current binding version' && typeof record.error.details?.currentVersion === 'string') return 'binding-conflict';
|
|
17
|
+
return '';
|
|
18
|
+
}
|
|
12
19
|
// A single project host owns this connection. Agents never receive its credential.
|
|
13
20
|
export class DeviceConnection {
|
|
14
21
|
constructor({ directory, origin, transport = sendMessage, allowLoopback = false }) {
|
|
@@ -180,9 +187,19 @@ export class DeviceConnection {
|
|
|
180
187
|
const file = path.join(this.directory, 'bindings', `${hash(message.payload.sessionId)}.json`);
|
|
181
188
|
return withFileLock(`${file}.lock`, async () => {
|
|
182
189
|
const versions = await readJSON(file, {});
|
|
183
|
-
|
|
190
|
+
let remote;
|
|
191
|
+
try { remote = await this.send(message, wire => ({ ...wire, payload: { ...wire.payload,
|
|
184
192
|
expectedBindingVersion: versions[wire.payload.expectedBindingVersion] || '',
|
|
185
|
-
} }));
|
|
193
|
+
} })); }
|
|
194
|
+
catch (error) {
|
|
195
|
+
const receipt = await readJSON(path.join(this.directory, 'outcomes', `${hash(message.id)}.json`), null);
|
|
196
|
+
if (rejectedBindingReason(receipt, localBinding.session.id)) {
|
|
197
|
+
versions.rejections ||= {};
|
|
198
|
+
versions.rejections[localBinding.bindingVersion] = message.id;
|
|
199
|
+
await atomicWrite(file, encode(versions));
|
|
200
|
+
}
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
186
203
|
if (remote.session.id !== localBinding.session.id || remote.session.generation !== localBinding.session.generation) {
|
|
187
204
|
fail('STALE_SESSION', 'Cloud and local binding generations differ');
|
|
188
205
|
}
|
|
@@ -192,16 +209,27 @@ export class DeviceConnection {
|
|
|
192
209
|
});
|
|
193
210
|
}
|
|
194
211
|
async ensureBinding(binding) {
|
|
195
|
-
const
|
|
196
|
-
if (
|
|
212
|
+
const status = await this.bindingStatus(binding);
|
|
213
|
+
if (status.status === 'ready') return;
|
|
214
|
+
if (status.status === 'conflict') fail('CONFLICT', 'Session binding requires a new host Session', { reason: status.reason });
|
|
197
215
|
await this.bind({ v: 2, id: `bind:${hash(binding.version)}`, type: 'session.bind', payload: {
|
|
198
216
|
sessionId: binding.sessionId, worktreeId: binding.worktreeId, agentId: binding.agentId,
|
|
199
217
|
expectedBindingVersion: binding.version,
|
|
200
218
|
} }, { session: { id: binding.sessionId, generation: binding.generation }, bindingVersion: binding.version });
|
|
201
219
|
}
|
|
202
|
-
async
|
|
220
|
+
async bindingStatus(binding) {
|
|
221
|
+
if (!binding) return { status: 'pending' };
|
|
203
222
|
const versions = await readJSON(path.join(this.directory, 'bindings', `${hash(binding.sessionId)}.json`), {});
|
|
204
|
-
if (versions[binding.version]) return
|
|
223
|
+
if (versions[binding.version]) return { status: 'ready' };
|
|
224
|
+
const requestId = versions.rejections?.[binding.version] || `bind:${hash(binding.version)}`;
|
|
225
|
+
const receipt = await readJSON(path.join(this.directory, 'outcomes', `${hash(requestId)}.json`), null);
|
|
226
|
+
const reason = rejectedBindingReason(receipt, binding.sessionId);
|
|
227
|
+
return reason ? { status: 'conflict', code: 'CONFLICT', reason } : { status: 'pending' };
|
|
228
|
+
}
|
|
229
|
+
async bindingReady(binding) {
|
|
230
|
+
const status = await this.bindingStatus(binding);
|
|
231
|
+
if (status.status === 'ready') return true;
|
|
232
|
+
if (status.status === 'conflict') return false;
|
|
205
233
|
this.enrolling ||= new Map();
|
|
206
234
|
if (!this.enrolling.has(binding.sessionId) && this.enrolling.size < 4) {
|
|
207
235
|
const job = this.ensureBinding(binding).catch(error => { this.lastError = error.code || 'UNAVAILABLE'; })
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { fail } from '../shared/protocol.mjs';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
|
+
import { performance } from 'node:perf_hooks';
|
|
5
|
+
import { pause } from '../shared/io.mjs';
|
|
4
6
|
|
|
5
7
|
const execFileAsync = promisify(execFile);
|
|
6
8
|
|
|
@@ -20,15 +22,35 @@ export async function lookupRepository(slug, {
|
|
|
20
22
|
fetcher = fetch,
|
|
21
23
|
token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN,
|
|
22
24
|
tokenProvider = keychainToken,
|
|
25
|
+
clock = () => performance.now(),
|
|
26
|
+
sleep = pause,
|
|
23
27
|
} = {}) {
|
|
24
28
|
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(slug) || slug.split('/').some(part => ['.', '..'].includes(part))) fail('INVALID_ARGUMENT', 'Invalid GitHub repository');
|
|
25
29
|
let url = new URL(`https://api.github.com/repos/${slug}`);
|
|
26
|
-
|
|
30
|
+
const deadline = clock() + 40000;
|
|
31
|
+
const checkDeadline = () => { if (clock() >= deadline) fail('UNAVAILABLE', 'GitHub repository identity verification timed out'); };
|
|
32
|
+
const authorization = token || await tokenProvider() || '';
|
|
33
|
+
checkDeadline();
|
|
34
|
+
const request = async target => {
|
|
35
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
36
|
+
checkDeadline();
|
|
37
|
+
let response;
|
|
38
|
+
try {
|
|
39
|
+
response = await fetcher(target, { redirect: 'manual', signal: AbortSignal.timeout(Math.max(1, Math.min(10000, Math.ceil(deadline - clock())))),
|
|
40
|
+
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'Context-Guard', ...(authorization ? { Authorization: `Bearer ${authorization}` } : {}) } });
|
|
41
|
+
} catch {
|
|
42
|
+
checkDeadline();
|
|
43
|
+
if (attempt === 2) fail('UNAVAILABLE', 'GitHub repository identity could not be verified');
|
|
44
|
+
}
|
|
45
|
+
checkDeadline();
|
|
46
|
+
if (response && ![502, 503, 504].includes(response.status)) return response;
|
|
47
|
+
await response?.body?.cancel();
|
|
48
|
+
if (attempt === 2) fail('UNAVAILABLE', 'GitHub repository is temporarily unavailable');
|
|
49
|
+
await sleep(Math.min(250 * 2 ** attempt, Math.max(1, deadline - clock())));
|
|
50
|
+
}
|
|
51
|
+
};
|
|
27
52
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
28
|
-
|
|
29
|
-
try { response = await fetcher(url, { redirect: 'manual', signal: AbortSignal.timeout(10000),
|
|
30
|
-
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'Context-Guard', ...(authorization ? { Authorization: `Bearer ${authorization}` } : {}) } }); }
|
|
31
|
-
catch { fail('UNAVAILABLE', 'GitHub repository identity could not be verified'); }
|
|
53
|
+
const response = await request(url);
|
|
32
54
|
if ([301, 302, 307, 308].includes(response.status)) {
|
|
33
55
|
const location = response.headers.get('location'); await response.body?.cancel();
|
|
34
56
|
if (!location) fail('UNAVAILABLE', 'GitHub redirect is missing');
|
|
@@ -38,11 +60,6 @@ export async function lookupRepository(slug, {
|
|
|
38
60
|
}
|
|
39
61
|
if (!response.ok) {
|
|
40
62
|
await response.body?.cancel();
|
|
41
|
-
if (!authorization && !keychainChecked && [401, 403, 404].includes(response.status)) {
|
|
42
|
-
keychainChecked = true;
|
|
43
|
-
authorization = await tokenProvider();
|
|
44
|
-
if (authorization) { attempt -= 1; continue; }
|
|
45
|
-
}
|
|
46
63
|
fail(response.status === 404 ? 'NOT_FOUND' : [401, 403].includes(response.status) ? 'FORBIDDEN' : 'UNAVAILABLE', 'GitHub repository is unavailable to this backend');
|
|
47
64
|
}
|
|
48
65
|
let value;
|
|
@@ -51,6 +68,7 @@ export async function lookupRepository(slug, {
|
|
|
51
68
|
for await (const chunk of response.body) { size += chunk.length; if (size > 1024 * 1024) fail('TOO_LARGE', 'GitHub response exceeds limit'); chunks.push(chunk); }
|
|
52
69
|
value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
53
70
|
} catch { fail('UNAVAILABLE', 'GitHub returned an invalid repository identity'); }
|
|
71
|
+
checkDeadline();
|
|
54
72
|
if (!Number.isSafeInteger(value.id) || value.id <= 0 || typeof value.full_name !== 'string') fail('UNAVAILABLE', 'GitHub returned an invalid repository identity');
|
|
55
73
|
return { repositoryId: String(value.id), slug: value.full_name };
|
|
56
74
|
}
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import { constants as fsConstants } from 'node:fs';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
6
|
import { atomicWrite, encode, readJSON, withFileLock } from '../shared/io.mjs';
|
|
6
7
|
import { MapError } from '../shared/map-model.mjs';
|
|
7
8
|
|
|
@@ -100,6 +101,11 @@ export async function rememberProject(project, { dir = globalWorkbenchDirectory(
|
|
|
100
101
|
} : previous?.runtime || null,
|
|
101
102
|
updatedAt: new Date().toISOString(),
|
|
102
103
|
};
|
|
104
|
+
if (previous) {
|
|
105
|
+
const { updatedAt: _previousUpdatedAt, ...previousContent } = previous;
|
|
106
|
+
const { updatedAt: _nextUpdatedAt, ...nextContent } = record;
|
|
107
|
+
if (isDeepStrictEqual(previousContent, nextContent)) return previous;
|
|
108
|
+
}
|
|
103
109
|
const next = {
|
|
104
110
|
version: 1,
|
|
105
111
|
projects: [...registry.projects.filter(item => item.projectId !== project.projectId), record]
|
|
@@ -15,6 +15,7 @@ import { memoryRequest, sessionMemoryDir, memoryConfigPath } from './memory.mjs'
|
|
|
15
15
|
import { MemorySyncCoordinator } from './sync-coordinator.mjs';
|
|
16
16
|
import { inspectRetiredSync } from './sync.mjs';
|
|
17
17
|
import { runtimeIdentity } from './runtime.mjs';
|
|
18
|
+
import { canRetryWorkbenchListen } from './listen.mjs';
|
|
18
19
|
import { Attachments } from './attachments.mjs';
|
|
19
20
|
import { ProtocolStore } from '../shared/protocol-store.mjs';
|
|
20
21
|
import { ProtocolBlobs, serveBlob } from '../shared/protocol-blobs.mjs';
|
|
@@ -144,6 +145,8 @@ export async function health(state) {
|
|
|
144
145
|
return result?.ok ? result.value : null;
|
|
145
146
|
}
|
|
146
147
|
export { loopbackJSON };
|
|
148
|
+
export { canRetryWorkbenchListen };
|
|
149
|
+
|
|
147
150
|
export async function startServer({ root, port = 8877, host = '127.0.0.1', fault, messageQueue = queueCodexMessage, repositoryLookup = lookupRepository } = {}) {
|
|
148
151
|
if (!['127.0.0.1', 'localhost'].includes(host)) throw new MapError('INVALID_HOST', 'Workbench only listens on loopback');
|
|
149
152
|
const directory = await defaultDirectoryAvailability();
|
|
@@ -476,6 +479,14 @@ export async function startServer({ root, port = 8877, host = '127.0.0.1', fault
|
|
|
476
479
|
const connection = await projectDevice();
|
|
477
480
|
const coordinator = new MemorySyncCoordinator({ project: sessionProject, sessionId, store: target, directory: syncDirectory,
|
|
478
481
|
managed: !!connection && await connection.supports('private-map-heads'),
|
|
482
|
+
request: async (...args) => {
|
|
483
|
+
if (connection && await connection.connected()) {
|
|
484
|
+
const binding = await protocolStore.registeredBinding(backendPrincipal, sessionId);
|
|
485
|
+
const status = await connection.bindingStatus(binding);
|
|
486
|
+
if (status.status === 'conflict') throw new MapError('CONFLICT', 'Session binding requires a new host Session', 409, { reason: status.reason });
|
|
487
|
+
}
|
|
488
|
+
return memoryRequest(...args);
|
|
489
|
+
},
|
|
479
490
|
display: async () => {
|
|
480
491
|
const identity = (await access.sessionRegistry()).find(item => item.id === sessionId);
|
|
481
492
|
return identity?.name ? { name: identity.name, platform: identity.platform } : null;
|
|
@@ -555,6 +566,13 @@ export async function startServer({ root, port = 8877, host = '127.0.0.1', fault
|
|
|
555
566
|
return refreshing;
|
|
556
567
|
}
|
|
557
568
|
async function cloudSyncStatus(viewId = 'main') {
|
|
569
|
+
if (viewId.startsWith('session:')) {
|
|
570
|
+
const connection = await projectDevice();
|
|
571
|
+
if (connection && await connection.connected()) {
|
|
572
|
+
const status = await connection.bindingStatus(await protocolStore.registeredBinding(backendPrincipal, viewId.slice(8)));
|
|
573
|
+
if (status.status === 'conflict') return { ...(syncCoordinators.get(viewId)?.snapshot() || {}), configured: true, status: 'conflict', error: status.code, reason: status.reason };
|
|
574
|
+
}
|
|
575
|
+
}
|
|
558
576
|
if (syncCoordinators.has(viewId)) return syncCoordinators.get(viewId).snapshot();
|
|
559
577
|
return { configured: false, status: 'disabled', cursor: 0, receivedCursor: 0, conflict: null, serviceAlive: false };
|
|
560
578
|
}
|
|
@@ -593,7 +611,17 @@ export async function startServer({ root, port = 8877, host = '127.0.0.1', fault
|
|
|
593
611
|
else await connection.bind(bindMessage, protocolBinding);
|
|
594
612
|
cloudBinding = { status: 'ready' };
|
|
595
613
|
}
|
|
596
|
-
catch (error) {
|
|
614
|
+
catch (error) {
|
|
615
|
+
const status = await connection.bindingStatus({ sessionId: prepared.sessionId, version: protocolBinding.bindingVersion });
|
|
616
|
+
cloudBinding = status.status === 'conflict' ? status : { status: 'pending', code: error.code || 'UNAVAILABLE' };
|
|
617
|
+
if (cloudBinding.status === 'conflict') {
|
|
618
|
+
const directory = path.join(sessionMemoryDir(prepared.sessionProject, prepared.sessionId), 'remote-sync');
|
|
619
|
+
const stateFile = path.join(directory, 'state.json');
|
|
620
|
+
await atomicWrite(stateFile, encode({ ...await readJSON(stateFile, {}), configured: true,
|
|
621
|
+
status: 'conflict', error: 'CONFLICT', reason: cloudBinding.reason,
|
|
622
|
+
pending: await readJSON(path.join(directory, 'outbox.json'), null) ? 1 : 0 }));
|
|
623
|
+
}
|
|
624
|
+
}
|
|
597
625
|
}
|
|
598
626
|
const lazyCloudMap = connection && await connection.supports('private-map-heads');
|
|
599
627
|
const sessionFiles = project.kind === 'git' && !lazyCloudMap && !stores.has(`session:${prepared.sessionId}`) ? await ensureSessionMap(prepared.sessionProject, prepared.sessionId) : null;
|
|
@@ -965,8 +993,8 @@ export async function startServer({ root, port = 8877, host = '127.0.0.1', fault
|
|
|
965
993
|
if (route.startsWith('/api/')) {
|
|
966
994
|
const actor = auth(req, url);
|
|
967
995
|
const viewId = viewFor(actor, url);
|
|
968
|
-
const activeStore = await storeFor(viewId);
|
|
969
996
|
if (route === '/api/cloud-sync' && req.method === 'GET') { isHuman(actor); return send(res, 200, await cloudSyncStatus(viewId)); }
|
|
997
|
+
const activeStore = await storeFor(viewId);
|
|
970
998
|
if (route === '/api/events' && req.method === 'GET') {
|
|
971
999
|
isHuman(actor); const id = url.searchParams.get('clientId');
|
|
972
1000
|
if (!id || id.length > 100) throw new MapError('INVALID_CLIENT', 'Invalid clientId');
|
|
@@ -1108,8 +1136,17 @@ export async function startServer({ root, port = 8877, host = '127.0.0.1', fault
|
|
|
1108
1136
|
});
|
|
1109
1137
|
server.requestTimeout = 10000; server.headersTimeout = 10000;
|
|
1110
1138
|
for (let attempt = 0; ; attempt++) {
|
|
1111
|
-
try {
|
|
1112
|
-
|
|
1139
|
+
try {
|
|
1140
|
+
await new Promise((resolve, reject) => {
|
|
1141
|
+
const onListening = () => { server.off('error', onError); resolve(); };
|
|
1142
|
+
const onError = error => { server.off('error', onError); server.off('listening', onListening); reject(error); };
|
|
1143
|
+
server.once('error', onError);
|
|
1144
|
+
try { server.listen(port === 0 ? 0 : port + attempt, '127.0.0.1', onListening); }
|
|
1145
|
+
catch (error) { onError(error); }
|
|
1146
|
+
});
|
|
1147
|
+
break;
|
|
1148
|
+
}
|
|
1149
|
+
catch (e) { if (!canRetryWorkbenchListen(e, port, attempt)) throw e; }
|
|
1113
1150
|
}
|
|
1114
1151
|
base = `http://127.0.0.1:${server.address().port}`;
|
|
1115
1152
|
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 };
|
|
@@ -126,6 +126,13 @@ export class MemorySyncCoordinator extends EventEmitter {
|
|
|
126
126
|
this.update(fields);
|
|
127
127
|
await atomicWrite(this.stateFile, encode(this.status));
|
|
128
128
|
}
|
|
129
|
+
async preserveBindingConflict(error) {
|
|
130
|
+
const reason = error?.details?.reason;
|
|
131
|
+
if (error?.code !== 'CONFLICT' || !['session-bound-elsewhere', 'binding-conflict'].includes(reason)) return false;
|
|
132
|
+
await this.persist({ status: 'conflict', error: 'CONFLICT', reason,
|
|
133
|
+
conflict: { code: 'CONFLICT', reason }, pending: await readJSON(this.outboxFile, null) ? 1 : 0 });
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
129
136
|
|
|
130
137
|
async start() {
|
|
131
138
|
await fs.mkdir(this.directory, { recursive: true });
|
|
@@ -272,6 +279,7 @@ export class MemorySyncCoordinator extends EventEmitter {
|
|
|
272
279
|
await this.persist({ status: equalDocument(acknowledged, this.store.doc) ? 'synced' : 'syncing', pending: 0, lastSyncedAt: result.persistedAt || new Date().toISOString() });
|
|
273
280
|
if (!equalDocument(acknowledged, this.store.doc)) await this.queueLocal();
|
|
274
281
|
} catch (error) {
|
|
282
|
+
if (await this.preserveBindingConflict(error)) return;
|
|
275
283
|
if (error.code === 'SESSION_REOPEN_REQUIRED') {
|
|
276
284
|
await this.createSessionGeneration();
|
|
277
285
|
return;
|
|
@@ -451,6 +459,7 @@ export class MemorySyncCoordinator extends EventEmitter {
|
|
|
451
459
|
this.retryDelay = this.retryMin;
|
|
452
460
|
} catch (error) {
|
|
453
461
|
clearTimeout(this.streamTimer);
|
|
462
|
+
if (await this.preserveBindingConflict(error)) break;
|
|
454
463
|
if (this.closed || this.managed) break;
|
|
455
464
|
if (this.abort?.signal.reason?.code === 'EVENT_STREAM_TIMEOUT') error = this.abort.signal.reason;
|
|
456
465
|
await this.schedule(async () => {
|
|
@@ -497,6 +506,7 @@ export class MemorySyncCoordinator extends EventEmitter {
|
|
|
497
506
|
if (this.status.pending) await this.flush();
|
|
498
507
|
else if (['offline', 'error', 'connecting'].includes(this.status.status)) await this.persist({ status: 'synced', error: null });
|
|
499
508
|
}).catch(async error => {
|
|
509
|
+
if (await this.preserveBindingConflict(error)) throw error;
|
|
500
510
|
if (!this.status.conflict) await this.persist({ status: error.code === 'UNAUTHORIZED' ? 'error' : 'offline', error: error.code || 'MEMORY_UNAVAILABLE' });
|
|
501
511
|
throw error;
|
|
502
512
|
});
|
|
@@ -77,6 +77,7 @@ export async function sessionSync(root, sessionId, action) {
|
|
|
77
77
|
const project = await resolveProject(root);
|
|
78
78
|
if (!(await bindingStatus(project, sessionId)).session.bound) throw new MapError('SESSION_BINDING_REQUIRED', 'Bind the actual Session before synchronization', 409);
|
|
79
79
|
if (!status.configured) throw new MapError('MEMORY_NOT_CONFIGURED', 'Connect this project using workbench connect', 503);
|
|
80
|
+
if (status.state?.status === 'conflict' && ['session-bound-elsewhere', 'binding-conflict'].includes(status.state.reason)) throw new MapError('CONFLICT', 'Session binding requires a new host Session', 409, { reason: status.state.reason });
|
|
80
81
|
if (['prepare', 'pull', 'checkpoint'].includes(action)) return prepareMemory(project, sessionId);
|
|
81
82
|
if (action === 'finish') {
|
|
82
83
|
const receipt = await synchronizeMemory(root, sessionId);
|