@michelj/context-guard 0.4.3 → 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.
Files changed (161) hide show
  1. package/Coordinator.md +88 -0
  2. package/Executor.md +53 -0
  3. package/README.md +89 -224
  4. package/README.zh-CN.md +89 -224
  5. package/SKILL.md +26 -684
  6. package/THIRD_PARTY_NOTICES.md +47 -0
  7. package/Tester.md +53 -0
  8. package/agents/openai.yaml +2 -2
  9. package/bin/build-runtime.mjs +96 -0
  10. package/bin/context-guard-skill.js +399 -78
  11. package/bin/postinstall.js +2 -2
  12. package/hooks.json +89 -13
  13. package/licenses/JSONParse-MIT.txt +24 -0
  14. package/licenses/Marked-MIT.txt +44 -0
  15. package/licenses/Portless-Apache-2.0.txt +201 -0
  16. package/package.json +35 -6
  17. package/prototype/LICENSES/Marked-MIT.txt +44 -0
  18. package/prototype/LICENSES/Ready-redistribution.txt +14 -0
  19. package/prototype/attachments.mjs +75 -0
  20. package/prototype/coordinator-markdown.mjs +283 -0
  21. package/prototype/coordinator-working-blot.mjs +124 -0
  22. package/prototype/vendor/marked.mjs +2189 -0
  23. package/prototype/workbench-app.js +5197 -0
  24. package/prototype/workbench-data.js +33 -0
  25. package/prototype/workbench-sync.mjs +898 -0
  26. package/prototype/workbench.css +1050 -0
  27. package/prototype/workbench.html +211 -0
  28. package/prototype/working-blot-atlas.png +0 -0
  29. package/references/agent-handoff.md +40 -0
  30. package/references/claude-runtime.md +120 -0
  31. package/references/cloud-sync-interface.md +66 -0
  32. package/references/design-current.md +14 -0
  33. package/references/map-mount.md +41 -0
  34. package/references/map-read.md +50 -0
  35. package/references/memory-definition.md +120 -0
  36. package/references/memory-filesystem-v2/Bug.en.md +162 -0
  37. package/references/memory-filesystem-v2/Bug.md +162 -0
  38. package/references/memory-filesystem-v2/Bug_Coordinater.md +8 -0
  39. package/references/memory-filesystem-v2/Bug_Executor.md +8 -0
  40. package/references/memory-filesystem-v2/Bug_Tester.md +7 -0
  41. package/references/memory-filesystem-v2/Idea.en.md +36 -0
  42. package/references/memory-filesystem-v2/Idea.md +36 -0
  43. package/references/memory-filesystem-v2/Node_Module_Index.en.md +88 -0
  44. package/references/memory-filesystem-v2/Node_Module_Index.md +88 -0
  45. package/references/memory-filesystem-v2/README.md +60 -0
  46. package/references/memory-filesystem-v2/Todo.en.md +137 -0
  47. package/references/memory-filesystem-v2/Todo.md +137 -0
  48. package/references/memory-filesystem-v2/Todo_Coordinater.md +7 -0
  49. package/references/memory-filesystem-v2/Todo_Executor.md +7 -0
  50. package/references/memory-filesystem-v2/Todo_Tester.md +7 -0
  51. package/references/named-workbench.md +124 -0
  52. package/references/plan-review.md +12 -0
  53. package/references/server-memory.md +276 -0
  54. package/references/test-check.md +7 -0
  55. package/references/user-reply.md +38 -0
  56. package/references/workbench-interface.md +531 -0
  57. package/roles.md +13 -0
  58. package/scripts/context_guard.py +1366 -7602
  59. package/scripts/context_guard_hook.py +1960 -711
  60. package/scripts/map_owns.py +699 -0
  61. package/scripts/shared/LICENSES/JSONParse-MIT.txt +24 -0
  62. package/scripts/shared/filesystem-v2.mjs +430 -0
  63. package/scripts/shared/io.mjs +117 -0
  64. package/scripts/shared/map-model.mjs +506 -0
  65. package/scripts/shared/memory-schema.mjs +13 -0
  66. package/scripts/shared/protocol-blobs.mjs +112 -0
  67. package/scripts/shared/protocol-map.mjs +146 -0
  68. package/scripts/shared/protocol-snapshots.mjs +84 -0
  69. package/scripts/shared/protocol-store.mjs +624 -0
  70. package/scripts/shared/protocol-workflow.mjs +226 -0
  71. package/scripts/shared/protocol.mjs +125 -0
  72. package/scripts/shared/vendor/jsonparse.cjs +413 -0
  73. package/scripts/workbench/access.mjs +496 -0
  74. package/scripts/workbench/attachments.mjs +92 -0
  75. package/scripts/workbench/browser-login.mjs +78 -0
  76. package/scripts/workbench/claude-runtime.mjs +372 -0
  77. package/scripts/workbench/cli.mjs +980 -0
  78. package/scripts/workbench/device-heartbeat.mjs +72 -0
  79. package/scripts/workbench/hook-status.mjs +38 -0
  80. package/scripts/workbench/inbox.mjs +155 -0
  81. package/scripts/workbench/journal.mjs +56 -0
  82. package/scripts/workbench/memory-merge.mjs +65 -0
  83. package/scripts/workbench/memory.mjs +252 -0
  84. package/scripts/workbench/named-proxy.mjs +108 -0
  85. package/scripts/workbench/named.mjs +152 -0
  86. package/scripts/workbench/portless-routes.mjs +51 -0
  87. package/scripts/workbench/project.mjs +327 -0
  88. package/scripts/workbench/projections.mjs +68 -0
  89. package/scripts/workbench/protocol-client.mjs +165 -0
  90. package/scripts/workbench/protocol-delivery.mjs +133 -0
  91. package/scripts/workbench/protocol-device.mjs +316 -0
  92. package/scripts/workbench/protocol-events.mjs +53 -0
  93. package/scripts/workbench/protocol-repository.mjs +58 -0
  94. package/scripts/workbench/reconcile.mjs +244 -0
  95. package/scripts/workbench/registry.mjs +111 -0
  96. package/scripts/workbench/runtime.mjs +54 -0
  97. package/scripts/workbench/server.mjs +1171 -0
  98. package/scripts/workbench/store.mjs +243 -0
  99. package/scripts/workbench/sync-coordinator.mjs +518 -0
  100. package/scripts/workbench/sync.mjs +86 -0
  101. package/references/context-template.md +0 -341
  102. package/references/feature-chain-methodology.md +0 -228
  103. package/references/register-template.md +0 -85
  104. package/references/task-case-template.md +0 -63
  105. package/tests/BC-20260618-063.sh +0 -116
  106. package/tests/BC-20260618-065.sh +0 -66
  107. package/tests/BC-20260626-080.sh +0 -48
  108. package/tests/BC-20260626-081.sh +0 -40
  109. package/tests/BC-20260626-082.sh +0 -32
  110. package/tests/BC-20260626-083.sh +0 -66
  111. package/tests/BC-20260627-084.sh +0 -74
  112. package/tests/BC-20260630-086.sh +0 -50
  113. package/tests/BC-20260630-087.sh +0 -103
  114. package/tests/BC-20260630-088.sh +0 -32
  115. package/tests/BC-20260630-089.sh +0 -63
  116. package/tests/BC-20260701-090.sh +0 -84
  117. package/tests/BC-20260702-096.sh +0 -48
  118. package/tests/BC-20260706-098.sh +0 -66
  119. package/tests/BC-20260707-099.sh +0 -47
  120. package/tests/BC-20260707-100.sh +0 -46
  121. package/tests/BC-20260707-101.sh +0 -47
  122. package/tests/BC-20260707-102.sh +0 -68
  123. package/tests/BC-20260707-103.sh +0 -59
  124. package/tests/BC-20260707-104.sh +0 -103
  125. package/tests/BC-20260707-105.sh +0 -109
  126. package/tests/BC-20260707-106.sh +0 -80
  127. package/tests/BC-20260707-107.sh +0 -74
  128. package/tests/BC-20260707-108.sh +0 -48
  129. package/tests/BC-20260707-109.sh +0 -56
  130. package/tests/BC-20260707-110.sh +0 -71
  131. package/tests/BC-20260707-111.sh +0 -70
  132. package/tests/BC-20260707-112.sh +0 -45
  133. package/tests/BC-20260707-113.sh +0 -73
  134. package/tests/BC-20260707-115.sh +0 -77
  135. package/tests/BC-20260707-116.sh +0 -77
  136. package/tests/BC-20260707-118.sh +0 -115
  137. package/tests/BC-20260707-119.sh +0 -47
  138. package/tests/BC-20260707-120.sh +0 -60
  139. package/tests/BC-20260707-121.sh +0 -66
  140. package/tests/BC-20260707-122.sh +0 -48
  141. package/tests/BC-20260707-123.sh +0 -43
  142. package/tests/BC-20260707-124.sh +0 -56
  143. package/tests/BC-20260707-125.sh +0 -64
  144. package/tests/BC-20260707-126.sh +0 -80
  145. package/tests/BC-20260707-127.sh +0 -88
  146. package/tests/BC-20260707-129.sh +0 -59
  147. package/tests/BC-20260707-130.sh +0 -69
  148. package/tests/BC-20260707-131.sh +0 -140
  149. package/tests/BC-20260707-132.sh +0 -150
  150. package/tests/BC-20260707-133.sh +0 -70
  151. package/tests/BC-20260708-136.sh +0 -210
  152. package/tests/BC-20260708-137.sh +0 -106
  153. package/tests/BC-20260708-138.sh +0 -168
  154. package/tests/BC-20260708-139.sh +0 -79
  155. package/tests/BC-20260709-002.sh +0 -63
  156. package/tests/BC-20260709-003.sh +0 -239
  157. package/tests/BC-20260709-006.sh +0 -76
  158. package/tests/BC-20260709-008.sh +0 -168
  159. package/tests/BC-20260710-001.sh +0 -61
  160. package/tests/BC-20260710-002.sh +0 -111
  161. package/tests/npm-install-smoke.sh +0 -53
@@ -0,0 +1,898 @@
1
+ import { copy, diffTrees, entries, same, validate } from '../scripts/shared/map-model.mjs';
2
+ export const ALL_SESSIONS = '__all__';
3
+ export const workbenchTimeoutMs = method => ['GET', 'HEAD'].includes(String(method).toUpperCase()) ? 30000 : 10000;
4
+ const labels = { loading: '连接中', readonly: '只读预览 · 请启动本地 Node 工作台', draft: '有未保存草稿', saving: '保存中', persisted: '已落盘 · 等待页面核对', synced: '已同步', conflict: '冲突 · 草稿已保留', offline: '连接中断 · 草稿已保留', error: '保存失败 · 草稿已保留' };
5
+ function stored(key) { try { const raw = localStorage.getItem(key); if (!raw) return null; try { return JSON.parse(raw); } catch { return { invalidJSON: true, raw }; } } catch { return null; } }
6
+ function diagnostic(error, fallback = '服务暂不可用') {
7
+ const message = String(error?.message || fallback).replace(/\s+/g, ' ').trim();
8
+ return error?.code && !message.startsWith(`[${error.code}]`) ? `[${error.code}] ${message}` : message;
9
+ }
10
+ function compact(message, limit = 72) {
11
+ const text = String(message || '').replace(/\s+/g, ' ').trim();
12
+ return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
13
+ }
14
+ function uniqueId() {
15
+ if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
16
+ const bytes = new Uint8Array(16);
17
+ if (globalThis.crypto?.getRandomValues) globalThis.crypto.getRandomValues(bytes);
18
+ else for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
19
+ bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80;
20
+ const hex = [...bytes].map(value => value.toString(16).padStart(2, '0')).join('');
21
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
22
+ }
23
+ const cloneValue = value => value === undefined ? undefined : copy(value);
24
+ function draftSequenceKey(value) {
25
+ if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) return `value:${JSON.stringify(value)}`;
26
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
27
+ if (typeof value.id === 'string' && value.id) return `id:${value.id}`;
28
+ if (typeof value.archiveKey === 'string' && value.archiveKey) return `archive:${value.archiveKey}`;
29
+ if (typeof value.operationId === 'string' && value.operationId) return `operation:${value.operationId}`;
30
+ if (typeof value.from === 'string' && typeof value.to === 'string') return `flow:${value.from}\0${value.to}\0${value.label || ''}`;
31
+ return null;
32
+ }
33
+ function indexedDraftSequence(values) {
34
+ const index = new Map();
35
+ for (const value of values) {
36
+ const key = draftSequenceKey(value);
37
+ if (!key || index.has(key)) throw new Error('无法确认列表项的唯一身份');
38
+ index.set(key, value);
39
+ }
40
+ return index;
41
+ }
42
+ function mergeDraftValue(base, local, remote) {
43
+ if (same(local, remote) || same(base, local)) return cloneValue(remote);
44
+ if (same(base, remote)) return cloneValue(local);
45
+ if ((base === undefined || Array.isArray(base)) && Array.isArray(local) && Array.isArray(remote)) {
46
+ const before = indexedDraftSequence(base || []), left = indexedDraftSequence(local), right = indexedDraftSequence(remote);
47
+ const shared = [...before.keys()].filter(key => left.has(key) && right.has(key));
48
+ const sharedSet = new Set(shared);
49
+ const sharedOrder = index => [...index.keys()].filter(key => sharedSet.has(key));
50
+ const localOrder = sharedOrder(left), remoteOrder = sharedOrder(right);
51
+ const localReordered = !same(shared, localOrder), remoteReordered = !same(shared, remoteOrder);
52
+ if (localReordered && remoteReordered && !same(localOrder, remoteOrder)) throw new Error('草稿和云端分别调整了同一列表顺序');
53
+ const preferred = localReordered && !remoteReordered ? left : right;
54
+ const other = preferred === left ? right : left;
55
+ const order = [...preferred.keys(), ...[...other.keys()].filter(key => !preferred.has(key))];
56
+ const result = [];
57
+ for (const key of order) {
58
+ const prior = before.get(key), mine = left.get(key), theirs = right.get(key);
59
+ if (mine === undefined && theirs === undefined) continue;
60
+ if (mine === undefined) {
61
+ if (prior === undefined) result.push(cloneValue(theirs));
62
+ else if (!same(prior, theirs)) throw new Error('删除与修改发生重叠');
63
+ } else if (theirs === undefined) {
64
+ if (prior === undefined) result.push(cloneValue(mine));
65
+ else if (!same(prior, mine)) throw new Error('修改与删除发生重叠');
66
+ } else result.push(mergeDraftValue(prior, mine, theirs));
67
+ }
68
+ return result;
69
+ }
70
+ const isObject = value => value && typeof value === 'object' && !Array.isArray(value);
71
+ if ((base === undefined || isObject(base)) && isObject(local) && isObject(remote)) {
72
+ const before = base || {};
73
+ return Object.fromEntries([...new Set([...Object.keys(before), ...Object.keys(local), ...Object.keys(remote)])]
74
+ .map(key => [key, mergeDraftValue(before[key], local[key], remote[key])])
75
+ .filter(([, value]) => value !== undefined));
76
+ }
77
+ throw new Error('草稿和云端修改了同一内容');
78
+ }
79
+ function draftTreeChanges(before, after) {
80
+ return diffTrees(before, after).length > 0 || !same(before.flows || [], after.flows || []);
81
+ }
82
+ export function reconcileRecoveryDraft(draft, remoteTree, remoteVersion) {
83
+ if (!draft?.doc?.root?.id || !remoteTree?.id) return { kind: 'conflict' };
84
+ // A request with an uncertain acknowledgement or an unfinished editor input
85
+ // must retain its original identity and content for explicit recovery.
86
+ if (draft.pendingRequest || draft.inputDraft) return { kind: draft.baseVersion === remoteVersion ? 'current' : 'conflict' };
87
+ try {
88
+ if (!draftTreeChanges(remoteTree, draft.doc.root)) return { kind: 'stale' };
89
+ if (!draft.baseTree?.id) return { kind: 'conflict' };
90
+ if (!draftTreeChanges(draft.baseTree, draft.doc.root)) return { kind: 'stale' };
91
+ if (draft.baseVersion === remoteVersion) return { kind: 'current' };
92
+ const root = mergeDraftValue(draft.baseTree, draft.doc.root, remoteTree);
93
+ validate({ root, flows: root.flows || [] });
94
+ return draftTreeChanges(remoteTree, root) ? { kind: 'merged', root } : { kind: 'stale' };
95
+ } catch { return { kind: 'conflict' }; }
96
+ }
97
+ export class WorkbenchSync {
98
+ constructor(adapter) {
99
+ this.a = adapter; this.config = window.__CG_SERVER;
100
+ this.backendInstance = String(this.config?.instance || '');
101
+ this.id = uniqueId();
102
+ try { this.id = sessionStorage.getItem('cg-sync-client') || this.id; sessionStorage.setItem('cg-sync-client', this.id); } catch {}
103
+ this.status = 'loading'; this.ready = false; this.inflight = null; this.pendingRequest = null;
104
+ this.recoveryBlocked = false;
105
+ this.startingRecovery = false;
106
+ this.revision = 0; this.cachedDiff = null;
107
+ const requestedSession = new URLSearchParams(location.search).get('session') || '';
108
+ this.composing = false; this.inputDraft = null; this.activeSession = requestedSession || ALL_SESSIONS; this.viewId = requestedSession ? `session:${requestedSession}` : 'main'; this.sessions = []; this.grants = {}; this.captureKey = null;
109
+ this.manualSession = Boolean(requestedSession);
110
+ this.pendingSession = requestedSession || '';
111
+ this.taskStates = new Map();
112
+ this.projectTaskStates = new Map();
113
+ this.refreshingAccess = null;
114
+ this.accessRefreshQueued = false;
115
+ // Cloud authentication scopes access; a deep link only selects the initial
116
+ // Session. Local Session-only views keep their existing restriction.
117
+ this.urlPinned = Boolean(requestedSession) && !this.config?.root?.startsWith('cloud:');
118
+ this.panel = document.createElement('details'); this.panel.id = 'cg-sync'; this.panel.className = 'set-block sync-settings';
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.repairButton = document.createElement('button'); this.repairButton.id = 'cg-sync-repair'; this.repairButton.hidden = true;
121
+ this.panel.querySelector('.sync-actions').append(this.repairButton);
122
+ if (this.config?.interfaceCapabilities?.deviceLogin && !this.config.root?.startsWith('cloud:')) {
123
+ const login = document.createElement('button'); login.type = 'button'; login.id = 'cg-cloud-login'; login.textContent = '连接 Cloud';
124
+ login.onclick = () => this.openCloudLogin(); this.panel.querySelector('.sync-actions').append(login);
125
+ }
126
+ this.repairButton.onclick = () => this.repair();
127
+ if (this.config?.root?.startsWith('cloud:') && this.config.interfaceCapabilities?.sessionCompletion) {
128
+ this.completeButton = document.createElement('button'); this.completeButton.type = 'button';
129
+ this.completeButton.id = 'cg-session-complete'; this.completeButton.textContent = '确认当前 Session 完成';
130
+ this.completeButton.hidden = !this.viewId.startsWith('session:');
131
+ this.completeButton.onclick = () => this.completeSession();
132
+ this.panel.querySelector('.sync-actions').append(this.completeButton);
133
+ }
134
+ document.getElementById('settings-menu').append(this.panel);
135
+ this.notice = document.createElement('span'); this.notice.className = 'sync-notice'; this.notice.setAttribute('role', 'status'); this.notice.hidden = true;
136
+ this.cloudIndicator = document.getElementById('cloud-sync-status');
137
+ document.getElementById('btn-settings').after(this.notice);
138
+ this.panel.querySelector('#cg-sync-retry').onclick = () => this.retry();
139
+ this.panel.querySelector('#cg-sync-initialize').onclick = () => this.initializeCurrent();
140
+ this.panel.querySelector('#cg-sync-export').onclick = () => this.export();
141
+ this.panel.querySelector('#cg-sync-import').onclick = () => this.panel.querySelector('#cg-sync-file').click();
142
+ this.panel.querySelector('#cg-sync-file').onchange = async e => { try { await this.preview(JSON.parse(await e.target.files[0].text())); } catch (err) { this.setStatus(this.status, '导入失败:' + err.message); } e.target.value = ''; };
143
+ this.panel.querySelector('#cg-sync-reload').onclick = () => this.reload();
144
+ this.panel.querySelector('#cg-sync-session').onchange = e => { this.selectSession(e.target.value); };
145
+ window.addEventListener('beforeunload', e => { if (this.dirty()) { this.saveDraft(); e.preventDefault(); e.returnValue = ''; } });
146
+ window.addEventListener('pagehide', () => {
147
+ this.disposed = true; clearTimeout(this.heartbeatTimer); clearTimeout(this.reconnectTimer); this.events?.close();
148
+ if (!this.config || this.dirty()) return;
149
+ fetch(this.endpoint('/api/presence'), { method: 'POST', headers: { ...(this.config.token ? { Authorization: `Bearer ${this.config.token}` } : {}), 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ clientId: this.id, version: this.version, dirty: false, closing: true }), keepalive: true }).catch(() => {});
150
+ });
151
+ window.addEventListener('pageshow', event => { if (event.persisted && this.config) { this.disposed = false; this.scheduleHeartbeat(); this.recoverConnection(); } });
152
+ this.setStatus(this.config ? 'loading' : 'readonly');
153
+ }
154
+ endpoint(route, viewId = this.viewId) {
155
+ const endpoint = `${this.config?.apiBase || ''}${route}`;
156
+ if (!route.startsWith('/api/')) return endpoint;
157
+ return `${endpoint}${endpoint.includes('?') ? '&' : '?'}view=${encodeURIComponent(viewId || 'main')}`;
158
+ }
159
+ bootstrapEndpoint() { return this.config?.apiBase ? this.endpoint('/bootstrap') : '/__context_guard/bootstrap'; }
160
+ async call(route, body, method = body === undefined ? 'GET' : 'POST', viewId = this.viewId) {
161
+ const payload = body === undefined ? undefined : JSON.stringify(body);
162
+ for (let attempt = 0; ; attempt++) {
163
+ const response = await fetch(this.endpoint(route, viewId), { method, headers: { ...(this.config.token ? { Authorization: `Bearer ${this.config.token}` } : {}), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, credentials: 'same-origin', body: payload, cache: 'no-store', signal: AbortSignal.timeout(workbenchTimeoutMs(method)) });
164
+ if (response.status === 401) throw Object.assign(new Error('登录已失效,请重新登录;草稿已保留'), { code: 'UNAUTHORIZED', serverResponse: true });
165
+ if (!response.headers.get('content-type')?.includes('application/json')) throw new Error('服务暂不可用,未收到有效响应;草稿已保留');
166
+ let result;
167
+ try { result = await response.json(); } catch { throw new Error('响应不完整;保留原请求等待重试'); }
168
+ if (!response.ok) {
169
+ // The lock rejected this exact commit before applying it. Replaying its
170
+ // stable operation ID is safe; a dead-owner recovery guard is not.
171
+ if (route === '/api/commit' && method === 'POST' && body?.operationId && attempt < 2 &&
172
+ result.error?.code === 'STATE_BUSY' && result.error.message === 'Shared state is busy; preserve lock and retry') {
173
+ await new Promise(resolve => setTimeout(resolve, 200 * (attempt + 1)));
174
+ continue;
175
+ }
176
+ const { message: _message, ...details } = result.error || {};
177
+ const e = new Error(diagnostic(result.error, '请求失败'));
178
+ Object.assign(e, details, { serverResponse: true }); throw e;
179
+ }
180
+ return result;
181
+ }
182
+ }
183
+ operations() {
184
+ if (!this.ready) return [];
185
+ if (!this.cachedDiff || this.cachedDiff.revision !== this.revision) {
186
+ const current = this.a.getRoot(), operations = diffTrees(this.baseTree, current);
187
+ if (!same(this.baseTree.flows || [], current.flows || [])) operations.push({ type: 'document', fields: { flows: copy(current.flows || []) } });
188
+ this.cachedDiff = { revision: this.revision, operations };
189
+ }
190
+ return this.cachedDiff.operations;
191
+ }
192
+ dirty() { return !!this.inputDraft || this.composing || !!this.inflight || !!this.pendingRequest || this.operations().length > 0; }
193
+ async completeSession() {
194
+ if (!this.completeButton || this.completeButton.disabled) return;
195
+ this.completeButton.disabled = true;
196
+ try {
197
+ if (this.dirty() || !this.ready || !this.viewId.startsWith('session:')) throw new Error('请先同步并审核当前 Session 地图');
198
+ const viewId = this.viewId;
199
+ if (this.completionRequest && this.completionRequest.viewId !== viewId) throw new Error('上一 Session 完成请求结果待确认,请先返回该 Session 重试');
200
+ if (!this.completionRequest) {
201
+ const publication = await this.call('/api/publication', undefined, 'GET', viewId);
202
+ if (viewId !== this.viewId || this.dirty() || publication.sessionVersion !== this.version) throw new Error('地图已变化,请重新读取并审核');
203
+ if (!confirm('已审核当前 Session 地图并确认完成?后续修改将使本次完成证明失效。')) return;
204
+ this.completionRequest = { viewId, body: { operationId: uniqueId(), sessionId: publication.sessionId,
205
+ generation: publication.generation, sessionVersion: publication.sessionVersion, sourceCommit: publication.sourceCommit } };
206
+ }
207
+ await this.call('/api/session-completion', this.completionRequest.body, 'POST', viewId);
208
+ this.completionRequest = null;
209
+ this.setStatus(this.status, '完成已确认;发布仍须通过 Main、CI 和任务门禁');
210
+ await this.refreshAccess();
211
+ } catch (error) {
212
+ if (error.serverResponse) this.completionRequest = null;
213
+ this.setStatus(this.status, error.message);
214
+ } finally { this.completeButton.disabled = false; }
215
+ }
216
+ legacyDeliveryEntries() {
217
+ const prefix = `cg-delivery:${this.config?.root}:`;
218
+ const entries = [];
219
+ try {
220
+ for (let index = 0; index < localStorage.length; index++) {
221
+ const key = localStorage.key(index);
222
+ if (key?.startsWith(prefix)) entries.push({ key, raw: localStorage.getItem(key) });
223
+ }
224
+ } catch { return null; }
225
+ return entries;
226
+ }
227
+ recoveryNotice(fallback) {
228
+ const issue = this.legacyDeliveryRecovery === null ? '无法检查旧派发记录;请导出浏览器数据后再迁移接口'
229
+ : this.legacyDeliveryRecovery?.length ? `发现 ${this.legacyDeliveryRecovery.length} 项旧派发记录;请导出核对,系统不会自动重派发` : '';
230
+ return issue ? [fallback, issue].filter(Boolean).join(';') : fallback;
231
+ }
232
+ loadRecovery() {
233
+ const restored = this.captureKey ? stored(this.captureKey) : null;
234
+ const legacy = stored('cg-workbench-maps-v16');
235
+ this.recovery = restored; this.legacy = legacy;
236
+ this.legacyDeliveryRecovery = this.legacyDeliveryEntries();
237
+ return !!(restored || legacy || this.legacyDeliveryRecovery?.length || this.legacyDeliveryRecovery === null);
238
+ }
239
+ async restoreRecoveryDraft() {
240
+ const draft = this.recovery;
241
+ if (!draft || draft.invalidJSON || !draft.doc?.root?.id) return 'none';
242
+ let candidate = draft;
243
+ if (draft.pendingRequest && draft.baseVersion !== this.version) {
244
+ if (!draft.pendingRequest.operationId || !Array.isArray(draft.pendingRequest.operations)) {
245
+ this.recoveryBlocked = true; return 'conflict';
246
+ }
247
+ // A lost response may mean this exact request committed. Replay its
248
+ // original identity before treating the browser copy as an unsent draft.
249
+ try {
250
+ const receipt = await this.call('/api/commit', draft.pendingRequest);
251
+ if (!receipt.committed) { this.recoveryBlocked = true; return 'conflict'; }
252
+ } catch (error) {
253
+ if (error.code !== 'VERSION_CONFLICT') { this.recoveryBlocked = true; return 'conflict'; }
254
+ }
255
+ candidate = { ...draft, pendingRequest: null };
256
+ try {
257
+ const latest = await this.call('/api/state');
258
+ if (latest.error || latest.recovery || !latest.doc?.root) { this.recoveryBlocked = true; return 'conflict'; }
259
+ if (this.dirty()) { this.recoveryBlocked = true; return 'conflict'; }
260
+ this.doc = latest.doc; this.version = latest.version; this.source = latest.source || null;
261
+ this.a.apply(this.doc); this.watchDocument(this.a.getRoot()); this.baseTree = copy(this.a.getRoot()); this.revision++;
262
+ } catch { this.recoveryBlocked = true; return 'conflict'; }
263
+ }
264
+ const reconciliation = reconcileRecoveryDraft(candidate, this.baseTree, this.version);
265
+ if (reconciliation.kind === 'stale') {
266
+ try { localStorage.removeItem(this.captureKey); }
267
+ catch { this.recoveryBlocked = true; return 'conflict'; }
268
+ this.recovery = null; this.recoveryBlocked = false;
269
+ return 'stale';
270
+ }
271
+ if (reconciliation.kind === 'conflict') { this.recoveryBlocked = true; return 'conflict'; }
272
+ this.recoveryBlocked = false;
273
+ const draftTree = reconciliation.kind === 'merged' ? reconciliation.root : copy(candidate.doc.root);
274
+ const draftFlows = Array.isArray(draftTree.flows) ? draftTree.flows
275
+ : reconciliation.kind === 'current' && Array.isArray(candidate.doc.flows) ? candidate.doc.flows : this.doc.flows || [];
276
+ this.doc = { ...this.doc, root: draftTree, flows: copy(draftFlows) };
277
+ this.a.apply(this.doc); this.watchDocument(this.a.getRoot());
278
+ this.baseTree = reconciliation.kind === 'merged' ? copy(this.baseTree)
279
+ : candidate.baseTree?.id ? copy(candidate.baseTree) : copy(this.doc.root);
280
+ this.pendingRequest = reconciliation.kind === 'merged' ? null : candidate.pendingRequest && Array.isArray(candidate.pendingRequest.operations)
281
+ ? copy(candidate.pendingRequest) : null;
282
+ this.inputDraft = candidate.inputDraft || null;
283
+ this.revision++;
284
+ this.setStatus('draft', reconciliation.kind === 'merged' ? '正在合并旧草稿与云端更新' : '正在恢复草稿');
285
+ // Flush directly during startup. Calling retry() here can race the freshly
286
+ // opened EventSource and re-enter start(); flush has the same idempotent
287
+ // request semantics without that connection-startup cycle.
288
+ await this.flush();
289
+ if (['conflict', 'offline', 'error'].includes(this.status)) this.recoveryBlocked = true;
290
+ if (this.recoveryBlocked) return this.status === 'conflict' ? 'conflict' : 'failed';
291
+ if (this.dirty() || this.status !== 'synced') return 'pending';
292
+ return reconciliation.kind === 'merged' ? 'merged' : 'restored';
293
+ }
294
+ setStatus(status, message = '') {
295
+ if (this.serverRecovery && status === 'synced') { status = 'error'; message = this.serverRecovery.message || '服务只读,需要恢复'; }
296
+ if (!message && this.journal?.message) message = this.journal.message;
297
+ this.status = status; this.panel.dataset.status = status;
298
+ this.panel.querySelector('#cg-sync-status').textContent = labels[status] + (message ? ` · ${message}` : '');
299
+ this.panel.querySelector('#cg-sync-version').textContent = this.version ? this.version.slice(0, 10) : '';
300
+ this.panel.querySelector('#cg-sync-version').dataset.version = this.version || '';
301
+ const attention = { readonly: '只读', conflict: '同步冲突', offline: '连接中断', error: '保存失败' }[status]
302
+ || (this.legacyDeliveryRecovery?.length || this.legacyDeliveryRecovery === null ? '旧交付待核对' : '');
303
+ /* 静态 htmlpreview 没有服务端:设置里仍记只读,顶栏不要跳出「只读」条。 */
304
+ this.notice.hidden = !this.config || !attention;
305
+ this.notice.textContent = attention ? `${attention}${status === 'error' && message ? ` · ${compact(message)}` : ''}` : '';
306
+ this.notice.title = attention ? this.panel.querySelector('#cg-sync-status').textContent + ';请打开设置中的同步与恢复' : '';
307
+ }
308
+ recoveryState(state) {
309
+ this.serverRecovery = state.recovery || state.error || null;
310
+ this.journal = state.journal || null;
311
+ if (this.repairButton) {
312
+ this.repairButton.hidden = !this.serverRecovery;
313
+ this.repairButton.textContent = state.recovery?.source === 'journal' ? '保留当前地图并恢复日志(历史有缺口)' : '重读文件并尝试恢复';
314
+ }
315
+ }
316
+ async repair() {
317
+ try {
318
+ if (this.dirty()) { this.saveDraft(); this.export(); }
319
+ const current = await this.call('/api/state');
320
+ this.recoveryState(current);
321
+ const result = await this.call('/api/recover', { baseVersion: current.version, acceptJournalGap: current.recovery?.source === 'journal' });
322
+ this.recoveryState(result);
323
+ if (result.recovery || result.error) { this.setStatus('error', this.serverRecovery.message); return; }
324
+ if (this.dirty()) { this.setStatus('conflict', '服务已恢复;旧草稿已保留,请比较后再提交'); return; }
325
+ await this.reload();
326
+ } catch (error) { this.setStatus('error', error.message); }
327
+ }
328
+ saveDraft() {
329
+ if (!this.captureKey) return;
330
+ try {
331
+ const draft = { project: this.doc.project, baseVersion: this.version, baseTree: this.baseTree, doc: { ...this.doc, root: copy(this.a.getRoot()) }, inputDraft: this.inputDraft, pendingRequest: this.pendingRequest };
332
+ localStorage.setItem(this.captureKey, JSON.stringify(draft));
333
+ } catch { this.setStatus('error', '浏览器无法保存恢复副本,请立即导出'); }
334
+ }
335
+ changed() {
336
+ if (!this.ready || !this.config) return;
337
+ this.revision++;
338
+ if (!this.dirty()) return;
339
+ this.saveDraft(); this.presence();
340
+ if (['conflict', 'offline', 'error'].includes(this.status)) return;
341
+ this.setStatus(this.inflight ? 'saving' : 'draft');
342
+ clearTimeout(this.timer); this.timer = setTimeout(() => this.flush(), 100);
343
+ }
344
+ async presence(checkpoint) {
345
+ if (!this.config || this.sessionUnavailable || this.switchingSession) return;
346
+ try { return await this.call('/api/presence', { clientId: this.id, dirty: this.dirty(), version: this.version, checkpoint }); } catch { this.setStatus('offline'); }
347
+ }
348
+ setInputDraft(input) {
349
+ const hadDraft = !!this.inputDraft;
350
+ this.inputDraft = input;
351
+ if (input) this.saveDraft();
352
+ // An input event must synchronously invalidate an older "synced" indicator.
353
+ // Keep it invalidated while the DOM change is being folded into operations,
354
+ // including the short hand-off where the input draft becomes null.
355
+ if ((input || hadDraft) && !['conflict', 'offline', 'error'].includes(this.status)) this.setStatus('draft');
356
+ this.presence();
357
+ }
358
+ async start() {
359
+ if (!this.config) return false;
360
+ try {
361
+ const state = await this.call('/api/state');
362
+ this.recoveryState(state);
363
+ this.doc = state.doc; this.version = state.version; this.source = state.source || null; this.captureKey = `cg-sync-draft:${this.config.root}:${this.viewId}`;
364
+ if (this.viewId.startsWith('session:')) this.pendingSession = '';
365
+ if (state.error || state.recovery) {
366
+ // Recovery blocks Map writes, not Session identity. Keep the bound
367
+ // Session visible so the page never misrepresents a recovery problem as
368
+ // an unbound workbench.
369
+ this.connect();
370
+ await this.refreshAccess();
371
+ await this.refreshCloudStatus();
372
+ throw new Error(state.error?.message || state.recovery?.message || '服务需要恢复');
373
+ }
374
+ if (state.doc?.root === null && state.doc.bootstrap === 'pending') {
375
+ const readOnlyMain = this.viewId === 'main' && state.source?.status !== 'local-folder';
376
+ this.initializationRequired = true;
377
+ this.panel.querySelector('#cg-sync-initialize').hidden = readOnlyMain;
378
+ if (readOnlyMain) this.a.pending?.();
379
+ this.connect(); await this.refreshAccess(); await this.refreshCloudStatus();
380
+ this.setStatus('error', readOnlyMain ? 'main 基线尚未发布;请切换已绑定 Session' : '尚未创建真实地图;可将当前页面设为真实地图');
381
+ return readOnlyMain;
382
+ }
383
+ if (!state.doc?.root) throw new Error('地图根节点无效');
384
+ this.initializationRequired = false;
385
+ this.a.apply(state.doc); this.watchDocument(this.a.getRoot()); this.baseTree = copy(this.a.getRoot()); this.ready = true;
386
+ const hasRecovery = this.loadRecovery();
387
+ this.startingRecovery = true;
388
+ this.connect(); await this.refreshAccess(); await this.refreshCloudStatus();
389
+ // Task stages/results live in the protocol store rather than the map
390
+ // version. Read them immediately so a fresh page does not show a stale
391
+ // receipt until the first heartbeat.
392
+ await this.refreshTaskStatuses().catch(() => {});
393
+ const recovery = hasRecovery ? await this.restoreRecoveryDraft().catch(() => 'failed') : 'none';
394
+ const sourceNotice = this.source?.status === 'binding-required' ? '需要绑定 GitHub 主仓库' : this.source?.needsReconcile ? 'main 已更新,等待地图校准' : '';
395
+ this.setStatus(recovery === 'conflict' ? 'conflict' : recovery === 'failed' ? 'error' : recovery === 'pending' ? 'draft' : 'synced',
396
+ this.recoveryNotice(recovery === 'conflict' ? '发现草稿与服务器版本冲突,请导出或导入比较' : recovery === 'failed' ? '草稿恢复失败,已保留副本' : recovery === 'pending' ? '草稿提交尚待页面核对' : recovery === 'stale' ? '旧草稿无未提交差异,已更新本地缓存' : recovery === 'merged' ? '旧草稿与云端的不同修改已合并' : recovery === 'restored' ? '已恢复并确认草稿' : hasRecovery ? '发现旧缓存,请导出或导入比较' : sourceNotice));
397
+ this.startingRecovery = false;
398
+ return true;
399
+ } catch (e) {
400
+ this.startingRecovery = false;
401
+ if (e.code === 'UNKNOWN_VIEW' && this.activeSession !== ALL_SESSIONS) {
402
+ if (!this.config?.root?.startsWith('cloud:')) {
403
+ this.activeSession = ALL_SESSIONS;
404
+ this.pendingSession = '';
405
+ this.viewId = 'main';
406
+ this.manualSession = false;
407
+ this.urlPinned = false;
408
+ this.captureKey = null;
409
+ return this.start();
410
+ }
411
+ this.pendingSession = this.activeSession;
412
+ this.viewId = 'main';
413
+ this.captureKey = null;
414
+ this.ready = false;
415
+ this.a.pending?.();
416
+ this.connect();
417
+ await this.refreshAccess();
418
+ await this.refreshCloudStatus();
419
+ this.setStatus('loading', '当前 Session 正在同步到 Cloud');
420
+ return false;
421
+ }
422
+ this.setStatus('error', e.message); return false;
423
+ }
424
+ }
425
+ connect() {
426
+ this.disposed = false;
427
+ this.scheduleHeartbeat();
428
+ this.events?.close();
429
+ const query = new URLSearchParams({ clientId: this.id }); if (this.config.token) query.set('token', this.config.token);
430
+ const endpoint = this.endpoint('/api/events');
431
+ this.events = new EventSource(`${endpoint}${endpoint.includes('?') ? '&' : '?'}${query}`, { withCredentials: true });
432
+ this.events.addEventListener('state', e => this.receive(JSON.parse(e.data)).catch(err => this.setStatus('error', err.message)));
433
+ this.events.addEventListener('access', () => this.refreshAccess().catch(err => this.setStatus('error', err.message)));
434
+ this.events.addEventListener('cloud-sync', e => this.renderCloudStatus(JSON.parse(e.data)));
435
+ this.events.addEventListener('checkpoint', async e => {
436
+ const { checkpoint } = JSON.parse(e.data);
437
+ if (!this.composing && document.activeElement?.isContentEditable) document.activeElement.blur();
438
+ if (!['conflict', 'offline', 'error'].includes(this.status)) await this.flush();
439
+ await this.presence(checkpoint);
440
+ });
441
+ this.events.onerror = () => this.recoverConnection();
442
+ this.events.onopen = async () => { if (this.switchingSession) return; if (this.status === 'offline') await this.retry(); else await this.presence(); };
443
+ }
444
+ scheduleHeartbeat() {
445
+ clearTimeout(this.heartbeatTimer);
446
+ if (this.disposed || this.heartbeatRunning) return;
447
+ this.heartbeatTimer = setTimeout(async () => {
448
+ this.heartbeatRunning = true;
449
+ const view = this.viewId;
450
+ try {
451
+ if (this.switchingSession) return;
452
+ if (this.sessionUnavailable) { await this.refreshAccess(); return; }
453
+ const head = await this.call('/api/presence', { clientId: this.id, version: this.version, dirty: this.dirty() });
454
+ if (view !== this.viewId || this.disposed) return;
455
+ if (this.pendingSession) await this.refreshAccess();
456
+ else if (this.pendingRequest && this.status === 'offline') await this.retry();
457
+ else if (head.version !== this.version) await this.receive(head);
458
+ else if (this.status === 'offline') await this.retry();
459
+ await this.refreshTaskStatuses().catch(() => {});
460
+ } catch { if (view === this.viewId && !this.disposed) await this.recoverConnection(); }
461
+ finally { this.heartbeatRunning = false; this.scheduleHeartbeat(); }
462
+ }, 10000);
463
+ }
464
+ async recoverConnection() {
465
+ if (this.reconnecting || this.disposed || this.switchingSession || this.sessionUnavailable) return;
466
+ this.reconnecting = true;
467
+ const view = this.viewId;
468
+ try {
469
+ if (this.dirty()) this.saveDraft();
470
+ this.setStatus('offline', '正在自动重连');
471
+ const response = await fetch(this.bootstrapEndpoint(), { cache: 'no-store', credentials: 'same-origin', signal: AbortSignal.timeout(workbenchTimeoutMs('GET')) });
472
+ if (response.status === 401) throw Object.assign(new Error('登录已失效,请重新登录;草稿已保留'), {code:'UNAUTHORIZED'});
473
+ if (!response.ok || !response.headers.get('content-type')?.includes('application/json')) throw new Error('服务暂不可用');
474
+ const config = await response.json();
475
+ if (view !== this.viewId || this.disposed) return;
476
+ if (config.root !== this.config.root || config.apiBase !== this.config.apiBase || config.protocol !== this.config.protocol) throw new Error('后端身份或协议不匹配,保留原连接配置');
477
+ this.backendInstance = String(config.instance || '');
478
+ this.config = config;
479
+ this.connect();
480
+ await this.retry();
481
+ } catch (error) {
482
+ if (view === this.viewId && !this.disposed) this.setStatus(error.code === 'UNAUTHORIZED' ? 'error' : 'offline', error.message);
483
+ } finally {
484
+ this.reconnecting = false;
485
+ clearTimeout(this.reconnectTimer);
486
+ if (!this.disposed && this.status === 'offline') this.reconnectTimer = setTimeout(() => this.recoverConnection(), 10000);
487
+ }
488
+ }
489
+ async receive(state) {
490
+ if (this.switchingSession || this.sessionUnavailable) return;
491
+ if (this.startingRecovery) return;
492
+ // While a Cloud deep link waits for its Session snapshot, this connection is
493
+ // intentionally subscribed to Main only so it can receive project access
494
+ // events. Main state must not make the pending Session look synchronized.
495
+ if (this.pendingSession) return;
496
+ if (state.viewId && state.viewId !== this.viewId) return;
497
+ this.recoveryState(state);
498
+ if (state.error || state.recovery) { this.setStatus('error', state.error?.message || '服务需要恢复'); return; }
499
+ if (this.initializationRequired) { await this.reload(); return; }
500
+ if (state.version === this.version) {
501
+ // Coordinator/CI transitions do not bump the map version.
502
+ await this.refreshTaskStatuses().catch(() => {});
503
+ if (!this.dirty() && !this.recoveryBlocked) this.setStatus('synced', state.projection?.status === 'failed' ? '索引失败;Agent须读当前节点' : state.projection?.status === 'pending' ? '索引更新中' : '');
504
+ return;
505
+ }
506
+ if (this.inflight) { this.deferredState = state; return; }
507
+ if (this.dirty()) { this.saveDraft(); this.setStatus('conflict'); return; }
508
+ // Only a new state read supersedes another read. Presence/version notices
509
+ // must not cancel the human's explicit reload while preserving a draft.
510
+ const generation = this.loadGeneration = (this.loadGeneration || 0) + 1;
511
+ const current = await this.call('/api/state');
512
+ if (generation !== this.loadGeneration) return;
513
+ this.recoveryState(current);
514
+ if (current.error || current.recovery || !current.doc) { this.setStatus('error', current.error?.message || '服务需要恢复'); return; }
515
+ if (this.dirty()) { this.setStatus('conflict'); return; }
516
+ this.doc = current.doc; this.version = current.version; this.source = current.source || null; this.a.apply(this.doc); this.watchDocument(this.a.getRoot()); this.baseTree = copy(this.a.getRoot()); this.revision++;
517
+ await this.presence(); this.setStatus('synced');
518
+ }
519
+ async flush() {
520
+ clearTimeout(this.timer);
521
+ if (!this.ready || this.serverRecovery || this.composing || ['conflict', 'offline', 'error'].includes(this.status)) return;
522
+ if (this.inflight) { await this.inflight; if (!this.inflight && !['conflict', 'offline', 'error'].includes(this.status) && this.operations().length) return this.flush(); return; }
523
+ const operations = this.operations();
524
+ // A preserved request is authoritative even when the current tree no
525
+ // longer differs from baseTree (for example, after a crash between the
526
+ // server commit and the local acknowledgement). Never drop that request
527
+ // just because the derived diff is empty.
528
+ if (!this.pendingRequest && !operations.length) { await this.presence(); return; }
529
+ const sentTree = copy(this.a.getRoot());
530
+ this.pendingRequest ||= { baseVersion: this.version, operationId: uniqueId(), operations };
531
+ const request = this.pendingRequest; this.saveDraft(); this.setStatus('saving');
532
+ this.inflight = (async () => {
533
+ try {
534
+ const result = await this.call('/api/commit', request);
535
+ if (!result.committed) throw new Error('操作未提交,请保留草稿并读取磁盘');
536
+ this.version = result.version; this.baseTree = sentTree; this.revision++;
537
+ this.doc = { ...this.doc, root: copy(sentTree) }; this.pendingRequest = null;
538
+ this.setStatus('persisted');
539
+ } catch (e) {
540
+ this.saveDraft();
541
+ const uncertain = ['TimeoutError', 'AbortError'].includes(e.name);
542
+ this.setStatus(e.code === 'VERSION_CONFLICT' ? 'conflict' : e.serverResponse ? 'error' : 'offline',
543
+ uncertain ? '请求结果待确认;草稿与原操作编号已保留,重试会核对同一请求' : e.message);
544
+ }
545
+ })();
546
+ await this.inflight; this.inflight = null;
547
+ if (['conflict', 'offline', 'error'].includes(this.status)) return;
548
+ if (this.operations().length) { this.saveDraft(); return this.flush(); }
549
+ if (this.deferredState) { const deferred = this.deferredState; this.deferredState = null; await this.receive(deferred); }
550
+ const acknowledgement = await this.presence();
551
+ if (acknowledgement?.error || acknowledgement?.recovery) { this.setStatus('error', acknowledgement.error?.message || '服务需要恢复'); return; }
552
+ if (!this.dirty() && acknowledgement?.synchronized) { localStorage.removeItem(this.captureKey); this.setStatus('synced'); }
553
+ else this.setStatus('draft');
554
+ }
555
+ async retry() {
556
+ if (this.switchingSession || this.sessionUnavailable) return;
557
+ if (this.retrying) return this.retrying;
558
+ this.retrying = this.retryNow();
559
+ try { return await this.retrying; } finally { this.retrying = null; }
560
+ }
561
+ async retryNow() {
562
+ if (this.inflight) await this.inflight;
563
+ if (!this.config) return;
564
+ if (!this.ready) { await this.start(); return; }
565
+ try {
566
+ // Reuse precisely the same payload and operationId after uncertain delivery.
567
+ if (this.pendingRequest) {
568
+ const result = await this.call('/api/commit', this.pendingRequest);
569
+ if (!result.committed) throw new Error('操作未提交;请保留草稿后读取磁盘');
570
+ const current = await this.call('/api/state');
571
+ this.recoveryState(current);
572
+ this.version = result.version;
573
+ // The request may predate later local typing. Never mark that later draft saved.
574
+ const { applyOperations } = await import('../scripts/shared/map-model.mjs');
575
+ this.baseTree = applyOperations({ root: this.baseTree }, this.pendingRequest.operations, { kind: 'human', sessionId: 'workbench' }).doc.root;
576
+ this.pendingRequest = null; this.revision++;
577
+ if (current.version !== result.version) { this.setStatus('conflict'); return; }
578
+ } else {
579
+ const current = await this.call('/api/state');
580
+ this.recoveryState(current);
581
+ if (current.error || current.recovery) throw new Error(current.error?.message || '服务需要恢复');
582
+ if (current.version !== this.version && this.dirty()) { this.setStatus('conflict'); return; }
583
+ if (current.version !== this.version) { await this.receive(current); return; }
584
+ }
585
+ this.setStatus('draft'); await this.flush(); if (!this.dirty()) this.setStatus('synced');
586
+ } catch (e) {
587
+ const uncertain = ['TimeoutError', 'AbortError'].includes(e.name);
588
+ if (uncertain) this.saveDraft();
589
+ this.setStatus(e.code === 'VERSION_CONFLICT' ? 'conflict' : uncertain ? 'offline' : 'error',
590
+ uncertain ? '请求结果待确认;草稿与原操作编号已保留,重试会核对同一请求' : e.message);
591
+ }
592
+ }
593
+ async initializeCurrent() {
594
+ if (!this.config || !this.initializationRequired) return;
595
+ const node = copy(this.a.getRoot());
596
+ if (!node?.id || !node?.title) { this.setStatus('error', '当前页面没有可初始化的地图'); return; }
597
+ const flows = copy(node.flows || []); delete node.flows;
598
+ this.setStatus('saving', '正在建立真实地图');
599
+ try {
600
+ const operations = [{ type: 'initialize', project: this.doc?.project || node.title, node }];
601
+ if (flows.length) operations.push({ type: 'document', fields: { flows } });
602
+ const result = await this.call('/api/commit', { baseVersion: this.version, operationId: uniqueId(), operations });
603
+ if (!result.committed) throw new Error('地图初始化未提交');
604
+ this.initializationRequired = false;
605
+ this.panel.querySelector('#cg-sync-initialize').hidden = true;
606
+ await this.reload();
607
+ } catch (e) { this.setStatus(e.code === 'VERSION_CONFLICT' ? 'conflict' : 'error', '初始化失败:' + e.message); }
608
+ }
609
+ download(value, name) { const url = URL.createObjectURL(new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' })); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }
610
+ export() {
611
+ this.download({ project: this.doc?.project, current: this.ready ? { ...this.doc, root: copy(this.a.getRoot()) } : null, draft: this.captureKey ? stored(this.captureKey) : null, legacy: stored('cg-workbench-maps-v16'), legacyDeliveries: this.legacyDeliveryEntries(), inputDraft: this.inputDraft }, 'context-guard-recovery.json');
612
+ }
613
+ async reload() {
614
+ if (!this.config) return;
615
+ if (this.dirty()) { this.saveDraft(); this.export(); }
616
+ const generation = this.loadGeneration = (this.loadGeneration || 0) + 1;
617
+ const current = await this.call('/api/state');
618
+ if (generation !== this.loadGeneration) {
619
+ if (this.switchingSession) throw new Error('地图读取已被更新替代,请重试');
620
+ return;
621
+ }
622
+ this.recoveryState(current);
623
+ if (current.error || current.recovery || !current.doc?.root) {
624
+ if (this.switchingSession) throw new Error(current.error?.message || current.recovery?.message || '目标地图尚未就绪');
625
+ const readOnlyMain = this.viewId === 'main' && current.source?.status !== 'local-folder';
626
+ this.ready = false; this.initializationRequired = true; this.doc = current.doc; this.version = current.version;
627
+ this.panel.querySelector('#cg-sync-initialize').hidden = readOnlyMain;
628
+ if (readOnlyMain) this.a.pending?.();
629
+ if (!this.events) { this.connect(); await this.refreshAccess(); }
630
+ this.setStatus('error', current.error?.message || current.recovery?.message || (readOnlyMain ? 'main 基线尚未发布' : '地图根节点尚未初始化')); return;
631
+ }
632
+ this.initializationRequired = false;
633
+ this.panel.querySelector('#cg-sync-initialize').hidden = true;
634
+ this.pendingRequest = null; this.inputDraft = null; this.doc = current.doc; this.version = current.version; this.source = current.source || null;
635
+ this.a.apply(current.doc); this.watchDocument(this.a.getRoot()); this.baseTree = copy(this.a.getRoot()); this.revision++; this.ready = true;
636
+ this.captureKey ||= `cg-sync-draft:${this.config.root}:${this.viewId}`;
637
+ if (this.switchingSession) return;
638
+ if (!this.events) { this.connect(); await this.refreshAccess(); }
639
+ const hasRecovery = this.loadRecovery();
640
+ await this.presence(); this.setStatus(this.source?.needsReconcile ? 'error' : 'synced', this.recoveryNotice(this.source?.needsReconcile ? '基线待更新或服务器不可达,保留上次版本' : hasRecovery ? '发现草稿/旧缓存,请导出或导入比较;未自动回写' : ''));
641
+ }
642
+ async preview(input) {
643
+ if (!this.config) throw new Error('请在本地 Node 工作台导入');
644
+ let doc = input.draft?.doc || input.current || input.doc || input;
645
+ const legacy = input.legacy || input;
646
+ if (!doc.root && legacy.repos) { const saved = legacy.repos[this.doc.project]; if (saved?.live) doc = { ...this.doc, root: saved.live, project: this.doc.project }; }
647
+ const preview = await this.call('/api/migration-preview', { doc });
648
+ const dialog = document.createElement('dialog'); dialog.style.cssText = 'max-width:85vw;max-height:80vh;overflow:auto';
649
+ const title = document.createElement('h3'); title.textContent = '逐项选择需要导入的差异(默认均不选)'; dialog.append(title);
650
+ const note = document.createElement('p'); note.textContent = `磁盘与导入副本已备份。共 ${preview.operations.length} 项。未知字段不会被删除。`; dialog.append(note);
651
+ const selected = [];
652
+ const choices = preview.operations.flatMap(op => op.type === 'update' ? Object.entries(op.fields).map(([key, value]) => ({ ...op, fields: { [key]: value } })) : [op]);
653
+ for (const op of choices) { const row = document.createElement('label'); row.style.display = 'block'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; row.append(checkbox, document.createTextNode(JSON.stringify(op))); dialog.append(row); selected.push({ op, checkbox }); }
654
+ const apply = document.createElement('button'); apply.textContent = '提交已选差异';
655
+ apply.onclick = async () => {
656
+ const operations = selected.filter(x => x.checkbox.checked).map(x => x.op); if (!operations.length) return;
657
+ apply.disabled = true;
658
+ this.setStatus('saving');
659
+ try { await this.call('/api/commit', { baseVersion: preview.baseVersion, operationId: uniqueId(), operations }); dialog.close(); dialog.remove(); await this.reload(); }
660
+ catch (e) { note.textContent = e.message; apply.disabled = false; this.setStatus('error', e.message); }
661
+ };
662
+ const cancel = document.createElement('button'); cancel.textContent = '取消'; cancel.onclick = () => { dialog.close(); dialog.remove(); };
663
+ dialog.append(apply, cancel); document.body.append(dialog); dialog.showModal();
664
+ }
665
+ async refreshAccess() {
666
+ if (!this.config) return;
667
+ if (this.refreshingAccess) { this.accessRefreshQueued = true; return this.refreshingAccess; }
668
+ this.refreshingAccess = (async () => {
669
+ do {
670
+ this.accessRefreshQueued = false;
671
+ await this.refreshAccessNow();
672
+ } while (this.accessRefreshQueued);
673
+ })();
674
+ try { return await this.refreshingAccess; } finally { this.refreshingAccess = null; }
675
+ }
676
+ async refreshAccessNow() {
677
+ if (this.completeButton) this.completeButton.hidden = !this.viewId.startsWith('session:');
678
+ const data = await this.call('/api/access'); const select = this.panel.querySelector('#cg-sync-session');
679
+ this.project = data.project || this.project || null;
680
+ const received = (data.sessions || []).map(item => typeof item === 'string' ? { id: item, name: '', platform: 'unknown', status: 'active', lastSeen: '' } : item);
681
+ const sessions = this.urlPinned
682
+ ? received.filter(item => item.id === this.activeSession || item.id === this.pendingSession)
683
+ : [...new Map(received.map(item => [item.id, item])).values()];
684
+ this.grants = this.urlPinned ? (data.grants || {}) : { ...this.grants, ...(data.grants || {}) };
685
+ const current = sessions.find(item => item.id === this.activeSession);
686
+ if (current && this.activeSession !== ALL_SESSIONS && this.config?.root?.startsWith('cloud:')) {
687
+ const publication = await this.call('/api/publication').catch(() => null);
688
+ if (publication?.status === 'ready') {
689
+ const result = await this.call('/api/publication', {});
690
+ if (result?.committed) {
691
+ this.activeSession = ALL_SESSIONS;
692
+ this.viewId = 'main';
693
+ this.sessionUnavailable = false;
694
+ this.events?.close(); this.events = null;
695
+ const url = new URL(location.href);
696
+ url.searchParams.delete('session');
697
+ history.replaceState(null, '', url);
698
+ setTimeout(() => this.reload().catch(error => this.setStatus('error', error.message)), 0);
699
+ return;
700
+ }
701
+ }
702
+ }
703
+ // A browser belongs to the Session explicitly present in its URL or selected
704
+ // by the human. Activity in another task must never silently switch maps.
705
+ if (this.pendingSession && current) {
706
+ const target = this.pendingSession;
707
+ this.pendingSession = '';
708
+ this.activeSession = target;
709
+ this.events?.close(); this.events = null;
710
+ this.viewId = `session:${target}`; this.captureKey = null;
711
+ setTimeout(() => this.reload().catch(error => this.setStatus('error', error.message)), 0);
712
+ return;
713
+ }
714
+ if (this.activeSession !== ALL_SESSIONS && !current) {
715
+ if (this.config?.root?.startsWith('cloud:')) {
716
+ const publicationView = this.pendingSession ? `session:${this.pendingSession}` : this.viewId;
717
+ const publication = await this.call('/api/publication', undefined, 'GET', publicationView).catch(() => null);
718
+ if (publication?.status === 'published') {
719
+ this.activeSession = ALL_SESSIONS;
720
+ this.viewId = 'main';
721
+ this.sessionUnavailable = false;
722
+ this.events?.close(); this.events = null;
723
+ const url = new URL(location.href);
724
+ url.searchParams.delete('session');
725
+ history.replaceState(null, '', url);
726
+ setTimeout(() => this.reload().catch(error => this.setStatus('error', error.message)), 0);
727
+ return;
728
+ }
729
+ }
730
+ if (!this.pendingSession) {
731
+ // Keep the canvas and its identity together. A disappearing Session is
732
+ // unavailable, not an implicit request to edit the Main map.
733
+ this.events?.close(); this.events = null;
734
+ this.sessionUnavailable = true;
735
+ clearTimeout(this.reconnectTimer);
736
+ this.a.setAccess([], this.activeSession, null, false, this.project?.main || null);
737
+ this.setStatus('error', '当前 Session 已不可用;请切换主工作台或其他 Session');
738
+ }
739
+ }
740
+ if (current && this.sessionUnavailable) {
741
+ this.sessionUnavailable = false;
742
+ this.setStatus('offline', 'Session 已恢复,正在核对未保存内容');
743
+ this.connect();
744
+ }
745
+ const unavailableMeta = this.activeSession !== ALL_SESSIONS && !current && !this.pendingSession
746
+ ? { ...(this.sessions.find(item => item.id === this.activeSession) || { id: this.activeSession, name: '当前 Session' }), bindingState: 'unavailable', status: 'unavailable' } : null;
747
+ if (unavailableMeta) sessions.push(unavailableMeta);
748
+ const pendingMeta = this.pendingSession && !current
749
+ ? { id: this.pendingSession, name: '当前 Session', platform: 'agent', status: 'syncing', bindingState: 'pending', lastSeen: '' }
750
+ : null;
751
+ this.sessions = pendingMeta ? [...sessions, pendingMeta] : sessions;
752
+ const all = document.createElement('option'); all.value = ALL_SESSIONS; all.textContent = '主工作台 · 全部 Session';
753
+ const pending = this.pendingSession && !current ? (() => {
754
+ const option = document.createElement('option'); option.value = this.pendingSession; option.textContent = '当前 Session · 同步中'; option.disabled = true; return option;
755
+ })() : null;
756
+ const options = [...(this.urlPinned ? [] : [all]), ...(pending ? [pending] : []), ...sessions.map(item => {
757
+ const option = document.createElement('option'); option.value = item.id;
758
+ option.disabled = item.bindingState === 'unavailable';
759
+ const displayName = [item.name || `${item.platform || 'Agent'} Session`, item.worktreeName, item.branch].filter(Boolean).join(' · ');
760
+ option.textContent = displayName; option.title = `${item.worktreeRoot || ''}\n${item.bindingState || 'bound'}`; return option;
761
+ })];
762
+ select.replaceChildren(...options); select.disabled = false; select.value = this.activeSession;
763
+ const active = sessions.find(item => item.id === this.activeSession) || null;
764
+ this.a.setAccess(active && !unavailableMeta ? this.grants?.[this.activeSession]?.nodes || [] : [], this.activeSession, active, this.activeSession === ALL_SESSIONS, this.project?.main || null);
765
+ }
766
+ renderCloudStatus(status) {
767
+ if (!this.cloudIndicator) return;
768
+ if (!status?.configured) { this.cloudIndicator.hidden = true; return; }
769
+ const value = status.status === 'synced' ? 'synced' : ['conflict', 'error'].includes(status.status) ? status.status : 'syncing';
770
+ 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;
773
+ }
774
+ async refreshCloudStatus() {
775
+ if (this.config?.root?.startsWith('cloud:')) { this.renderCloudStatus({ configured: true, status: this.pendingSession ? 'syncing' : 'synced' }); return; }
776
+ try { this.renderCloudStatus(await this.call('/api/cloud-sync')); }
777
+ catch { if (this.cloudIndicator) this.cloudIndicator.hidden = true; }
778
+ }
779
+ async selectSession(sessionId) {
780
+ if (this.switchingSession) return false;
781
+ if (this.retrying) await this.retrying;
782
+ if (this.switchingSession) return false;
783
+ if (sessionId !== ALL_SESSIONS && !this.sessions.some(item => item.id === sessionId && item.bindingState !== 'unavailable')) return false;
784
+ if (this.dirty()) {
785
+ await this.flush();
786
+ if (this.dirty()) { this.setStatus(this.status, '当前视图仍有未保存内容,暂不能切换'); return false; }
787
+ }
788
+ if (this.switchingSession) return false;
789
+ const privateCloud = this.config?.root?.startsWith('cloud:') && this.config.root !== 'cloud:overview';
790
+ const nextView = sessionId === ALL_SESSIONS || (!privateCloud && this.project?.kind !== 'git') ? 'main' : `session:${sessionId}`;
791
+ const previous = Object.fromEntries(['activeSession', 'pendingSession', 'manualSession', 'viewId', 'captureKey', 'doc', 'version', 'source', 'baseTree', 'ready', 'initializationRequired', 'pendingRequest', 'inputDraft', 'serverRecovery', 'revision', 'sessionUnavailable'].map(key => [key, this[key]]));
792
+ const previousTree = copy(this.a.getRoot());
793
+ this.switchingSession = true;
794
+ try {
795
+ this.activeSession = sessionId;
796
+ this.sessionUnavailable = false;
797
+ this.pendingSession = '';
798
+ this.manualSession = true;
799
+ if (nextView !== this.viewId) {
800
+ this.events?.close(); this.events = null; this.viewId = nextView; this.captureKey = null;
801
+ await this.reload();
802
+ } else await this.refreshAccess();
803
+ const url = new URL(location.href);
804
+ if (sessionId === ALL_SESSIONS) url.searchParams.delete('session'); else url.searchParams.set('session', sessionId);
805
+ history.replaceState(null, '', url);
806
+ const hasRecovery = this.loadRecovery();
807
+ this.setStatus(this.source?.needsReconcile ? 'error' : 'synced', this.recoveryNotice(this.source?.needsReconcile ? '基线待更新,保留上次版本' : hasRecovery ? '发现保留的草稿,请导入比较' : ''));
808
+ this.connect();
809
+ setTimeout(() => this.refreshAccess().catch(error => this.setStatus('error', error.message)), 0);
810
+ return true;
811
+ } catch (error) {
812
+ Object.assign(this, previous);
813
+ if (previous.doc) this.a.apply({ ...previous.doc, root: previousTree });
814
+ this.panel.querySelector('#cg-sync-initialize').hidden = !previous.initializationRequired || (previous.viewId === 'main' && previous.source?.status !== 'local-folder');
815
+ this.loadGeneration = (this.loadGeneration || 0) + 1;
816
+ if (!this.sessionUnavailable) this.connect();
817
+ this.setStatus('error', '无法切换地图:' + error.message);
818
+ return false;
819
+ } finally { this.switchingSession = false; }
820
+ }
821
+ isAllSessions() { return this.activeSession === ALL_SESSIONS; }
822
+ grantsFor(sessionId) { return this.grants?.[sessionId]?.nodes || []; }
823
+ async reviewTask(input) {
824
+ if (!this.config.interfaceCapabilities?.humanReview || this.viewId !== 'main') throw new Error('请在 Cloud 主工作台验收');
825
+ const key = `cg-task-review:${this.config.root}:${JSON.stringify(input)}`;
826
+ const request = stored(key) || { ...input, operationId: uniqueId() };
827
+ localStorage.setItem(key, JSON.stringify(request));
828
+ const result = await this.call('/api/task-review', request);
829
+ if (result.operationId !== request.operationId || result.review?.taskId !== input.taskId || result.review?.resultVersion !== input.resultVersion || result.review?.decision !== input.decision) throw new Error('验收回执不匹配,请重试原操作');
830
+ localStorage.removeItem(key);
831
+ return result;
832
+ }
833
+ async connectCloud(password) {
834
+ const id = uniqueId();
835
+ const result = await this.call('/api/v2/messages', { v: 2, id, type: 'auth.open', payload: { repository: 'auto', clientId: 'local-backend', password } });
836
+ if (result.id !== id || result.ok !== true) throw new Error('Cloud 没有返回有效连接回执');
837
+ return result.data;
838
+ }
839
+ openCloudLogin() {
840
+ const dialog = document.createElement('dialog');
841
+ dialog.innerHTML = '<form><h3>连接 Cloud</h3><p>使用项目已配置的 Cloud 地址,仓库自动识别。</p><label>密码 <input type="password" autocomplete="current-password" required></label><p role="status"></p><button type="submit">连接</button> <button type="button" data-cancel>取消</button></form>';
842
+ const input = dialog.querySelector('input'), status = dialog.querySelector('[role="status"]'), submit = dialog.querySelector('[type="submit"]');
843
+ dialog.querySelector('[data-cancel]').onclick = () => dialog.close();
844
+ dialog.onclose = () => { input.value = ''; dialog.remove(); };
845
+ dialog.querySelector('form').onsubmit = async event => {
846
+ event.preventDefault(); if (submit.disabled) return;
847
+ const password = input.value; input.value = ''; submit.disabled = true; status.textContent = '正在连接…';
848
+ try { await this.connectCloud(password); status.textContent = '后端已连接,Session 正在自动同步。'; }
849
+ catch { status.textContent = '连接未确认。请检查密码、Cloud 地址或网络;本地数据未清空。'; }
850
+ finally { submit.disabled = false; }
851
+ };
852
+ document.body.append(dialog); dialog.showModal(); input.focus();
853
+ }
854
+ watchTask(taskId, sessionId, state = '') {
855
+ if (!taskId || !sessionId) return;
856
+ const previous = this.taskStates.get(taskId) || {};
857
+ this.taskStates.set(taskId, { ...previous, taskId, sessionId, ...(state ? { state } : {}) });
858
+ }
859
+ watchDocument(root) {
860
+ const pending = [root];
861
+ while (pending.length) {
862
+ const value = pending.pop();
863
+ if (!value || typeof value !== 'object') continue;
864
+ if (value.dispatch?.task_id && value.dispatch?.session_id) this.watchTask(value.dispatch.task_id, value.dispatch.session_id, value.dispatch.status);
865
+ pending.push(...Object.values(value));
866
+ }
867
+ }
868
+ taskState(taskId) { return this.taskStates.get(taskId)?.state || ''; }
869
+ taskResult(taskId) { return this.taskStates.get(taskId)?.result || null; }
870
+ projectTaskState(kind, nodeId, itemId) { return this.projectTaskStates?.get(`${kind}:${nodeId}:${itemId}`) || null; }
871
+ async refreshTaskStatuses() {
872
+ if (!this.config.interfaceCapabilities?.taskDispatch || this.taskStatusRunning) return;
873
+ this.taskStatusRunning = true;
874
+ try {
875
+ const result = await this.call('/api/task-status', { tasks: [...this.taskStates.values()].map(({ taskId, sessionId }) => ({ taskId, sessionId })) });
876
+ let changed = false;
877
+ for (const item of result.tasks || []) {
878
+ const previous = this.taskStates.get(item.taskId);
879
+ if (!previous || previous.state !== item.state || previous.version !== item.version) changed = true;
880
+ this.taskStates.set(item.taskId, item);
881
+ }
882
+ const projectTasks = new Map();
883
+ for (const item of result.projectTasks || []) {
884
+ const key = `${item.kind}:${item.nodeId}:${item.itemId}`, previous = projectTasks.get(key);
885
+ if (!previous || Date.parse(item.updatedAt) >= Date.parse(previous.updatedAt)) projectTasks.set(key, item);
886
+ }
887
+ if (!same([...(this.projectTaskStates || new Map()).entries()], [...projectTasks.entries()])) changed = true;
888
+ this.projectTaskStates = projectTasks;
889
+ if (changed) this.a.statusChanged?.();
890
+ } finally { this.taskStatusRunning = false; }
891
+ }
892
+ async toggleAccess(ids) {
893
+ if (this.activeSession === ALL_SESSIONS) { this.setStatus(this.status, '请先选择具体 Session 再调整授权'); return; }
894
+ if (!this.activeSession) { this.setStatus(this.status, '尚无真实 Agent 会话,请先启动会话'); return; }
895
+ try { await this.call('/api/access', { sessionId: this.activeSession, nodes: ids }); await this.refreshAccess(); }
896
+ catch (e) { this.setStatus(this.status, '授权失败:' + e.message); }
897
+ }
898
+ }