@sanqianx/ai-coding-event-bridge-console 0.6.0 → 0.7.0
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/README.md +15 -4
- package/console/index.html +37 -11
- package/package.json +1 -1
- package/src/bin.js +5 -1
- package/src/bridge/core/bridge.js +49 -8
- package/src/bridge/core/commit-projection.js +4 -4
- package/src/bridge/core/compaction.js +2 -2
- package/src/bridge/core/consumer-registry.js +18 -8
- package/src/bridge/core/event-schema.js +1 -1
- package/src/bridge/core/lock.js +6 -4
- package/src/server.js +52 -3
package/README.md
CHANGED
|
@@ -48,6 +48,15 @@ otherwise.
|
|
|
48
48
|
| `GET /api/turns?project&date&cursor&limit` | Merged turns + `annotations` (commit shas per turn) |
|
|
49
49
|
| `GET /api/sessions?project=` | Session index |
|
|
50
50
|
| `GET /api/search?q&project&limit` | Substring search over conversation bodies |
|
|
51
|
+
| `GET /api/journals` | Global and registered project journal identities |
|
|
52
|
+
| `POST /api/internal/workbench-event` | Private native Workbench capture; runtime token, loopback, no browser Origin |
|
|
53
|
+
|
|
54
|
+
The private capture route uses `KB_EVENT_BRIDGE_RUNTIME_TOKEN`. It resolves the
|
|
55
|
+
Git identity and requires an already registered code directory. Stable event
|
|
56
|
+
keys deduplicate retries; only original prompts, successful final answers and
|
|
57
|
+
turn endings enter the same bridge journal used by hooks. Credentials and
|
|
58
|
+
injected knowledge are not accepted as event metadata. Shell proxies must not
|
|
59
|
+
expose this route to browser callers.
|
|
51
60
|
|
|
52
61
|
Turn merging: for a registered project, the project journal and the global
|
|
53
62
|
journal (legacy pre-import events) are both read, filtered by repo identity,
|
|
@@ -57,7 +66,9 @@ one journal's sequence space; `openTurnIdsAtCommit` binding works across both.
|
|
|
57
66
|
## Notes
|
|
58
67
|
|
|
59
68
|
- Node >= 18, CommonJS, zero runtime dependencies beyond the core package.
|
|
60
|
-
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
69
|
+
- Public query APIs read journals; project import/unregister changes registration.
|
|
70
|
+
The authenticated Workbench endpoint delegates capture to the core bridge,
|
|
71
|
+
not a second conversation database.
|
|
72
|
+
- Consumer cursors and ACKs are journal-specific. Compaction may retire sealed
|
|
73
|
+
evidence only after every applicable consumer has acknowledged that journal;
|
|
74
|
+
creating a commit archive is not proof that the engine consumed it.
|
package/console/index.html
CHANGED
|
@@ -185,6 +185,10 @@
|
|
|
185
185
|
.btn-upg { height: 26px; padding: 0 12px; border-radius: 7px; background: var(--text); color: #16171a; font-size: 12px; font-weight: 600; flex-shrink: 0; }
|
|
186
186
|
.btn-upg:hover { background: #fff; }
|
|
187
187
|
.btn-upg:disabled { opacity: .45; cursor: not-allowed; }
|
|
188
|
+
body.kb-embedded[data-kb-theme="light"] { --bg-side:#f8f9fb; --bg-main:#fff; --bg-card:#f8f9fb; --bg-input:#edf1f4; --bg-hover:#edf1f4; --bg-active:#e6edf3; --line:#dce2e8; --line-soft:#dce2e8; --text:#202733; --text-2:#687587; --text-3:#8e99a7; --label:#687587; --blue:#516f89; --green:#397761; --red:#a05252; color-scheme:light; }
|
|
189
|
+
body.kb-embedded[data-kb-theme="dark"] { --bg-side:#1b2028; --bg-main:#20252d; --bg-card:#252b34; --bg-input:#2c333e; --bg-hover:#2c333e; --bg-active:#293748; --line:#39424f; --line-soft:#39424f; --text:#e6ebf0; --text-2:#a0aaba; --text-3:#788393; --label:#a0aaba; --blue:#8aa8cf; --green:#81b29a; --red:#d88b8b; color-scheme:dark; }
|
|
190
|
+
body.kb-embedded .topbar h1 { display:none; }
|
|
191
|
+
body.kb-embedded .topbar { padding:8px 24px; }
|
|
188
192
|
</style>
|
|
189
193
|
</head>
|
|
190
194
|
<body>
|
|
@@ -226,7 +230,7 @@
|
|
|
226
230
|
<span class="svg-i" data-i="gear"></span>
|
|
227
231
|
</button>
|
|
228
232
|
<div class="hist-wrap">
|
|
229
|
-
<button class="hist-btn" id="hist-btn"
|
|
233
|
+
<button class="hist-btn" id="hist-btn">历史</button>
|
|
230
234
|
<div class="hist-pop" id="hist-pop">
|
|
231
235
|
<div class="ph">历史对话</div>
|
|
232
236
|
<div id="hist-list"></div>
|
|
@@ -333,8 +337,14 @@ function toast(ok, text) {
|
|
|
333
337
|
}, 3200);
|
|
334
338
|
}
|
|
335
339
|
|
|
340
|
+
var embedQuery = new URLSearchParams(location.search);
|
|
341
|
+
var embedded = embedQuery.get('embed') === '1';
|
|
342
|
+
var moduleApiBase = /^\/api\/[a-z0-9/-]+$/i.test(embedQuery.get('apiBase') || '') ? embedQuery.get('apiBase').replace(/\/$/, '') : '/api';
|
|
343
|
+
var projectEpoch = 0;
|
|
344
|
+
var selectionEpoch = 0;
|
|
345
|
+
var embedVisible = true;
|
|
336
346
|
function api(path, options) {
|
|
337
|
-
return fetch(path, options).then(function (res) {
|
|
347
|
+
return fetch(path.replace(/^\/api(?=\/|$)/, moduleApiBase), options).then(function (res) {
|
|
338
348
|
return res.text().then(function (body) {
|
|
339
349
|
var payload = null;
|
|
340
350
|
try { payload = body ? JSON.parse(body) : null; } catch (_) { payload = null; }
|
|
@@ -376,7 +386,7 @@ function timeOf(iso) {
|
|
|
376
386
|
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
|
377
387
|
}
|
|
378
388
|
|
|
379
|
-
var SOURCE_LABELS = { 'claude-code': 'Claude', claude: 'Claude', codex: 'Codex', opencode: 'OpenCode' };
|
|
389
|
+
var SOURCE_LABELS = { 'claude-code': 'Claude', claude: 'Claude', codex: 'Codex', opencode: 'OpenCode', zcode: 'ZCode' };
|
|
380
390
|
|
|
381
391
|
/* ---------- 状态 ---------- */
|
|
382
392
|
|
|
@@ -490,6 +500,7 @@ function loadMoreTurns() {
|
|
|
490
500
|
|
|
491
501
|
function fetchTurns(options) {
|
|
492
502
|
if (!state.project) return Promise.resolve();
|
|
503
|
+
var epoch = projectEpoch;
|
|
493
504
|
var append = options && options.append;
|
|
494
505
|
state.loadingTurns = true;
|
|
495
506
|
if (!append) { state.turns = []; state.cursor = null; }
|
|
@@ -498,13 +509,14 @@ function fetchTurns(options) {
|
|
|
498
509
|
+ '&order=desc'
|
|
499
510
|
+ (state.cursor ? '&cursor=' + encodeURIComponent(state.cursor) : '');
|
|
500
511
|
return api(query).then(function (payload) {
|
|
501
|
-
if (!state.project || payload.project !== state.project.id) return;
|
|
512
|
+
if (epoch !== projectEpoch || !state.project || payload.project !== state.project.id) return;
|
|
502
513
|
state.turns = state.turns.concat(payload.turns || []);
|
|
503
514
|
state.cursor = payload.nextCursor || null;
|
|
504
515
|
state.totalTurns = payload.totalTurns || state.turns.length;
|
|
505
516
|
state.loadingTurns = false;
|
|
506
517
|
renderLive();
|
|
507
518
|
}).catch(function (err) {
|
|
519
|
+
if (epoch !== projectEpoch) return;
|
|
508
520
|
state.loadingTurns = false;
|
|
509
521
|
renderLive();
|
|
510
522
|
toast(false, '加载对话失败:' + err.message);
|
|
@@ -514,6 +526,7 @@ function fetchTurns(options) {
|
|
|
514
526
|
function selectProject(id, options) {
|
|
515
527
|
var project = state.projects.find(function (p) { return p.id === id; });
|
|
516
528
|
if (!project) return;
|
|
529
|
+
projectEpoch++;
|
|
517
530
|
state.project = project;
|
|
518
531
|
state.turns = [];
|
|
519
532
|
state.cursor = null;
|
|
@@ -688,13 +701,14 @@ document.getElementById('hist-list').addEventListener('click', function (ev) {
|
|
|
688
701
|
document.getElementById('back-btn').classList.add('show');
|
|
689
702
|
showDocLoading(true);
|
|
690
703
|
// 懒加载:此刻才请求这一份文档,其余历史永不加载
|
|
691
|
-
|
|
704
|
+
var epoch = projectEpoch;
|
|
705
|
+
fetch(moduleApiBase + '/commits/' + encodeURIComponent(file) + '?project=' + encodeURIComponent(state.project.id))
|
|
692
706
|
.then(function (res) {
|
|
693
707
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
694
708
|
return res.text();
|
|
695
709
|
})
|
|
696
710
|
.then(function (text) {
|
|
697
|
-
if (state.currentDocFile !== file) return;
|
|
711
|
+
if (epoch !== projectEpoch || state.currentDocFile !== file) return;
|
|
698
712
|
var parsed = parseSealedDoc(text);
|
|
699
713
|
var title = doc.subject || parsed.fm.subject || (parsed.fm.commitSha || file).slice(0, 12);
|
|
700
714
|
var subParts = [];
|
|
@@ -901,20 +915,32 @@ setInterval(loadHealth, 30000);
|
|
|
901
915
|
* URL 参数:?embed=1 进入嵌入态(默认折叠,sidebar=1 保持展开)。
|
|
902
916
|
* 就绪后回发 {type:'kb:ready', app:'event-bridge'};独立打开时不受控、侧栏恒显。 */
|
|
903
917
|
function embedAllowedOrigin(origin) {
|
|
904
|
-
|
|
905
|
-
try { const u = new URL(origin); return u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]'; }
|
|
918
|
+
try { return origin === (document.referrer ? new URL(document.referrer).origin : location.origin); }
|
|
906
919
|
catch { return false; }
|
|
907
920
|
}
|
|
908
921
|
function setEmbedSideVisible(visible) { document.body.classList.toggle('embed-collapse', !visible); }
|
|
909
|
-
window.addEventListener('message', function (ev) {
|
|
910
|
-
if (!embedAllowedOrigin(ev.origin)) return;
|
|
922
|
+
window.addEventListener('message', async function (ev) {
|
|
923
|
+
if (ev.source !== window.parent || !embedAllowedOrigin(ev.origin)) return;
|
|
911
924
|
const d = ev.data;
|
|
912
925
|
if (!d || typeof d !== 'object') return;
|
|
926
|
+
if (d.type === 'kb:theme') { document.body.dataset.kbTheme = d.theme === 'dark' ? 'dark' : 'light'; return; }
|
|
927
|
+
if (d.type === 'kb:visibility') { embedVisible = d.visible === true; return; }
|
|
928
|
+
if (d.type === 'kb:refresh' && embedVisible && state.project) { fetchTurns(); refreshHist(); return; }
|
|
929
|
+
if (d.type === 'kb:select-project') {
|
|
930
|
+
var requestEpoch = ++selectionEpoch;
|
|
931
|
+
var find = function () { return state.projects.find(function (p) { return p.id === d.project || p.path === d.project; }); };
|
|
932
|
+
if (!find()) await loadProjects();
|
|
933
|
+
if (requestEpoch !== selectionEpoch) return;
|
|
934
|
+
var p = find();
|
|
935
|
+
if (p) selectProject(p.id);
|
|
936
|
+
ev.source.postMessage({ type: 'kb:projects', app: 'event-bridge', ok: !!p, projectId: p && p.id, requestId: d.requestId }, ev.origin);
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
913
939
|
if (d.type === 'kb:sidebar') setEmbedSideVisible(!!d.visible);
|
|
914
940
|
});
|
|
915
941
|
(function () {
|
|
916
942
|
const q = new URLSearchParams(location.search);
|
|
917
|
-
if (q.get('embed') === '1') setEmbedSideVisible(q.get('sidebar')
|
|
943
|
+
if (q.get('embed') === '1') { document.body.classList.add('kb-embedded'); document.body.dataset.kbTheme = q.get('theme') === 'dark' ? 'dark' : 'light'; setEmbedSideVisible(q.get('sidebar') === '1'); }
|
|
918
944
|
if (window.parent !== window) window.parent.postMessage({ type: 'kb:ready', app: 'event-bridge' }, '*');
|
|
919
945
|
})();
|
|
920
946
|
</script>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanqianx/ai-coding-event-bridge-console",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Durable AI coding event bridge, all in one: capture connectors and installers (Claude Code, Codex, OpenCode, ZCode), journal with commit boundaries and per-commit conversation sealing, headless query, and a local conversation explorer console.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"private": false,
|
package/src/bin.js
CHANGED
|
@@ -52,8 +52,12 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
52
52
|
const home = bridgeHome(get('home'));
|
|
53
53
|
const port = Number(get('port') || 8790);
|
|
54
54
|
const host = get('host') || '127.0.0.1';
|
|
55
|
-
const server = http.createServer(createConsoleServer({ home }));
|
|
55
|
+
const server = http.createServer(createConsoleServer({ home, runtimeToken: process.env.KB_EVENT_BRIDGE_RUNTIME_TOKEN }));
|
|
56
56
|
await new Promise((resolve) => server.listen(port, host, resolve));
|
|
57
|
+
let stopping = false;
|
|
58
|
+
const stop = () => { if (stopping) return; stopping = true; server.closeAllConnections?.(); server.close(() => process.exit(0)); };
|
|
59
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, stop);
|
|
60
|
+
process.on('message', message => { if (message?.type === 'kb:shutdown') stop(); });
|
|
57
61
|
console.log(`ai-coding-event-bridge console serving on http://${host}:${port}`);
|
|
58
62
|
console.log(` home: ${home}`);
|
|
59
63
|
console.log(` import projects from the sidebar; conversations of imported`);
|
|
@@ -9,6 +9,9 @@ const { compactJournal } = require('./compaction');
|
|
|
9
9
|
const { journalFor, globalJournalDir } = require('./journal-router');
|
|
10
10
|
const projectRegistry = require('./project-registry');
|
|
11
11
|
const { sealCommitConversations, sealDirFor } = require('./commit-projection');
|
|
12
|
+
const { resolveRepoContext } = require('./repo-context');
|
|
13
|
+
const { validateAndNormalizeEvent } = require('./event-schema');
|
|
14
|
+
const { notifyConsumers } = require('../connectors/claude-code/notify');
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* Stable host-facing facade over the Bridge runtime. Hosts (Project-Knowledge,
|
|
@@ -32,18 +35,28 @@ function createBridge({ homeDir } = {}) {
|
|
|
32
35
|
// Pure reads on a machine where no Bridge home exists yet must not
|
|
33
36
|
// materialize one: status queries stay side-effect free.
|
|
34
37
|
const homeExists = () => fs.existsSync(home);
|
|
38
|
+
const selectJournal = (journalId = 'global') => {
|
|
39
|
+
if (journalId === 'global') return getJournal();
|
|
40
|
+
const project = projectRegistry.listProjects(home).find(p => p.id === journalId);
|
|
41
|
+
if (!project) throw Object.assign(new Error('journal is not registered'), { code: 'JOURNAL_NOT_FOUND' });
|
|
42
|
+
return journalFor(home, project.id);
|
|
43
|
+
};
|
|
35
44
|
|
|
36
45
|
return {
|
|
37
46
|
homeDir: home,
|
|
38
47
|
|
|
39
|
-
async
|
|
48
|
+
async listJournals() {
|
|
49
|
+
return [{ journalId: 'global', projectId: null }, ...projectRegistry.listProjects(home).map(p => ({ journalId: p.id, projectId: p.id, repoPath: p.path }))];
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
async getHighWatermark({ journalId = 'global' } = {}) {
|
|
40
53
|
if (!homeExists()) return 0;
|
|
41
|
-
const bounds = await
|
|
54
|
+
const bounds = await selectJournal(journalId).getBounds();
|
|
42
55
|
return bounds.lastSequence;
|
|
43
56
|
},
|
|
44
57
|
|
|
45
58
|
async readEvents(options = {}) {
|
|
46
|
-
return
|
|
59
|
+
return selectJournal(options.journalId).readEvents(options);
|
|
47
60
|
},
|
|
48
61
|
|
|
49
62
|
// Host-side injection for tests and explicit workflows; automatic capture
|
|
@@ -52,6 +65,30 @@ function createBridge({ homeDir } = {}) {
|
|
|
52
65
|
return getJournal().appendConversationEvent(event);
|
|
53
66
|
},
|
|
54
67
|
|
|
68
|
+
async captureWorkbenchEvent(input) {
|
|
69
|
+
if (!['user_prompt', 'assistant_response', 'session_end'].includes(input?.eventType)
|
|
70
|
+
|| typeof input.projectPath !== 'string' || !path.isAbsolute(input.projectPath)
|
|
71
|
+
|| typeof input.eventKey !== 'string' || !input.eventKey.startsWith('workbench:')
|
|
72
|
+
|| !input.sessionId || !input.turnId || input.content && (typeof input.content !== 'string' || input.content.length > 200000)) {
|
|
73
|
+
throw Object.assign(new Error('Invalid native workbench event'), { code: 'CAPTURE_INVALID' });
|
|
74
|
+
}
|
|
75
|
+
const repo = await resolveRepoContext(input.projectPath);
|
|
76
|
+
const project = projectRegistry.findProject(home, repo.repoIdentity);
|
|
77
|
+
if (!project || fs.realpathSync(project.path) !== fs.realpathSync(input.projectPath)) {
|
|
78
|
+
throw Object.assign(new Error('Workbench project is not registered'), { code: 'PROJECT_NOT_REGISTERED' });
|
|
79
|
+
}
|
|
80
|
+
const event = validateAndNormalizeEvent({
|
|
81
|
+
source: input.source, eventType: input.eventType, role: input.eventType === 'user_prompt' ? 'user' : input.eventType === 'assistant_response' ? 'assistant' : null,
|
|
82
|
+
content: input.eventType === 'session_end' ? undefined : input.content, sessionId: input.sessionId, turnId: input.turnId, eventKey: input.eventKey,
|
|
83
|
+
repoIdentity: repo.repoIdentity, projectPath: repo.projectPath, branch: repo.branch, headAtCapture: repo.headAtCapture,
|
|
84
|
+
capturedAt: typeof input.capturedAt === 'string' && !Number.isNaN(Date.parse(input.capturedAt)) ? input.capturedAt : new Date().toISOString(),
|
|
85
|
+
identityConfidence: 'exact', captureStatus: 'complete', meta: { origin: 'claude-ai-workbench', outcome: input.outcome === 'failed' ? 'failed' : 'completed' },
|
|
86
|
+
});
|
|
87
|
+
const appended = await journalFor(home, repo.repoIdentity).appendConversationEvent(event);
|
|
88
|
+
await notifyConsumers(home, { sequence: appended.sequence, eventId: appended.eventId });
|
|
89
|
+
return { status: 'captured', ...appended };
|
|
90
|
+
},
|
|
91
|
+
|
|
55
92
|
async appendCommitBoundary({ projectId, repoIdentity, commitSha, parentShas, parents, branch, subject, committedAt, operationId, meta } = {}) {
|
|
56
93
|
if (!repoIdentity) {
|
|
57
94
|
throw new Error('appendCommitBoundary requires repoIdentity');
|
|
@@ -105,7 +142,8 @@ function createBridge({ homeDir } = {}) {
|
|
|
105
142
|
// exists on disk, drop the sealed prefix. On any miss the prefix
|
|
106
143
|
// stays and the next boundary's seal window covers it (self-heal).
|
|
107
144
|
if (projection.sealed || (projection.reason === 'already-sealed' && fs.existsSync(projection.path))) {
|
|
108
|
-
const
|
|
145
|
+
const minAck = await getRegistry().getMinAck({ journalId: project.id });
|
|
146
|
+
const trim = await journal.trimThrough(minAck === null ? result.sequence : Math.min(result.sequence, minAck));
|
|
109
147
|
projection.trimmedThrough = trim.trimmedThrough;
|
|
110
148
|
}
|
|
111
149
|
}
|
|
@@ -115,6 +153,7 @@ function createBridge({ homeDir } = {}) {
|
|
|
115
153
|
}
|
|
116
154
|
return {
|
|
117
155
|
sequence: result.sequence,
|
|
156
|
+
journalId: routed ? projectRegistry.findProject(home, repoIdentity).id : 'global',
|
|
118
157
|
bridgeCursorAtCommit,
|
|
119
158
|
openTurnIdsAtCommit: result.openTurnIdsAtCommit,
|
|
120
159
|
previousRepoBoundarySequence: result.previousRepoBoundarySequence,
|
|
@@ -138,16 +177,18 @@ function createBridge({ homeDir } = {}) {
|
|
|
138
177
|
return getRegistry().getConsumers();
|
|
139
178
|
},
|
|
140
179
|
|
|
141
|
-
async ackConsumerCursor(name, sequence) {
|
|
142
|
-
|
|
180
|
+
async ackConsumerCursor(name, sequence, options = {}) {
|
|
181
|
+
const high = await this.getHighWatermark(options);
|
|
182
|
+
if (sequence > high) throw Object.assign(new Error('ack exceeds journal high watermark'), { code: 'ACK_OUT_OF_RANGE' });
|
|
183
|
+
return getRegistry().ackConsumerCursor(name, sequence, options);
|
|
143
184
|
},
|
|
144
185
|
|
|
145
186
|
async unregisterConsumer(name) {
|
|
146
187
|
return getRegistry().unregisterConsumer(name);
|
|
147
188
|
},
|
|
148
189
|
|
|
149
|
-
async compact({ throughSequence } = {}) {
|
|
150
|
-
return compactJournal(
|
|
190
|
+
async compact({ throughSequence, journalId = 'global' } = {}) {
|
|
191
|
+
return compactJournal(selectJournal(journalId), getRegistry(), { throughSequence, journalId });
|
|
151
192
|
},
|
|
152
193
|
|
|
153
194
|
async getHealth() {
|
|
@@ -206,7 +206,7 @@ async function sealCommitConversations({ journal, sealDir, boundary } = {}) {
|
|
|
206
206
|
* commits/<day>-uncommitted-<seq>.md, then trim the journal. Idempotent per
|
|
207
207
|
* run: nothing happens while no new events arrived after the last boundary.
|
|
208
208
|
*/
|
|
209
|
-
async function sealUncommittedTail({ journal, sealDir, maxAgeDays = 14, now = Date.now() } = {}) {
|
|
209
|
+
async function sealUncommittedTail({ journal, sealDir, maxAgeDays = 14, now = Date.now(), throughSequence = Infinity } = {}) {
|
|
210
210
|
if (!journal) throw new Error('sealUncommittedTail requires journal');
|
|
211
211
|
if (typeof sealDir !== 'string' || !sealDir) throw new Error('sealUncommittedTail requires sealDir');
|
|
212
212
|
const records = await journal.readEvents({});
|
|
@@ -236,7 +236,7 @@ async function sealUncommittedTail({ journal, sealDir, maxAgeDays = 14, now = Da
|
|
|
236
236
|
};
|
|
237
237
|
const result = await sealCommitConversations({ journal, sealDir, boundary });
|
|
238
238
|
if (result.sealed || (result.reason === 'already-sealed' && fs.existsSync(result.path))) {
|
|
239
|
-
const trim = await journal.trimThrough(appended.sequence);
|
|
239
|
+
const trim = await journal.trimThrough(Math.min(appended.sequence, throughSequence));
|
|
240
240
|
return { ...result, trimmedThrough: trim.trimmedThrough };
|
|
241
241
|
}
|
|
242
242
|
return result;
|
|
@@ -248,7 +248,7 @@ async function sealUncommittedTail({ journal, sealDir, maxAgeDays = 14, now = Da
|
|
|
248
248
|
* successful seal and a crashed trim, and adopts pre-upgrade journals that
|
|
249
249
|
* already carry sealed commits.
|
|
250
250
|
*/
|
|
251
|
-
async function reconcileTrim({ journal, sealDir } = {}) {
|
|
251
|
+
async function reconcileTrim({ journal, sealDir, throughSequence = Infinity } = {}) {
|
|
252
252
|
if (!journal) throw new Error('reconcileTrim requires journal');
|
|
253
253
|
if (typeof sealDir !== 'string' || !sealDir) throw new Error('reconcileTrim requires sealDir');
|
|
254
254
|
const records = await journal.readEvents({});
|
|
@@ -258,7 +258,7 @@ async function reconcileTrim({ journal, sealDir } = {}) {
|
|
|
258
258
|
.sort((a, b) => b.sequence - a.sequence);
|
|
259
259
|
for (const boundary of boundaries) {
|
|
260
260
|
if (fs.existsSync(path.join(sealDir, `${String(boundary.commitSha).toLowerCase()}.md`))) {
|
|
261
|
-
const trim = await journal.trimThrough(boundary.sequence);
|
|
261
|
+
const trim = await journal.trimThrough(Math.min(boundary.sequence, throughSequence));
|
|
262
262
|
return { trimmedThrough: trim.trimmedThrough, commitSha: boundary.commitSha, reason: trim.reason };
|
|
263
263
|
}
|
|
264
264
|
}
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* Never removes data beyond minAck; requires at least one registered consumer;
|
|
6
6
|
* runs under the same journal lock as appendEvent/appendCommitBoundary.
|
|
7
7
|
*/
|
|
8
|
-
async function compactJournal(journal, consumerRegistry, { throughSequence } = {}) {
|
|
9
|
-
const minAck = await consumerRegistry.getMinAck();
|
|
8
|
+
async function compactJournal(journal, consumerRegistry, { throughSequence, journalId = 'global' } = {}) {
|
|
9
|
+
const minAck = await consumerRegistry.getMinAck({ journalId });
|
|
10
10
|
if (minAck === null || minAck === undefined) {
|
|
11
11
|
return { compactedThrough: null, removedCount: 0, reason: 'no-consumers' };
|
|
12
12
|
}
|
|
@@ -59,7 +59,7 @@ class ConsumerRegistry {
|
|
|
59
59
|
});
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
async ackConsumerCursor(name, sequence) {
|
|
62
|
+
async ackConsumerCursor(name, sequence, { journalId = 'global' } = {}) {
|
|
63
63
|
if (!Number.isInteger(sequence) || sequence < 0) {
|
|
64
64
|
throw new TypeError('ack sequence must be a non-negative integer');
|
|
65
65
|
}
|
|
@@ -71,12 +71,14 @@ class ConsumerRegistry {
|
|
|
71
71
|
err.code = 'CONSUMER_NOT_REGISTERED';
|
|
72
72
|
throw err;
|
|
73
73
|
}
|
|
74
|
-
if (
|
|
75
|
-
|
|
74
|
+
if (journalId === 'global') entry.ack = Math.max(entry.ack || 0, sequence);
|
|
75
|
+
else {
|
|
76
|
+
if (!consumesJournal(entry, journalId)) throw new Error(`consumer ${name} is not registered for journal ${journalId}`);
|
|
77
|
+
entry.cursors = { ...(entry.cursors || {}), [journalId]: Math.max((entry.cursors || {})[journalId] || 0, sequence) };
|
|
76
78
|
}
|
|
77
79
|
entry.lastSeenAt = new Date().toISOString();
|
|
78
80
|
this._write(state);
|
|
79
|
-
return { name, ack: entry.ack };
|
|
81
|
+
return { name, journalId, ack: journalId === 'global' ? entry.ack : entry.cursors[journalId] };
|
|
80
82
|
});
|
|
81
83
|
}
|
|
82
84
|
|
|
@@ -88,14 +90,22 @@ class ConsumerRegistry {
|
|
|
88
90
|
});
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
async getMinAck() {
|
|
93
|
+
async getMinAck({ journalId = 'global' } = {}) {
|
|
92
94
|
if (!fs.existsSync(this.consumersFile)) return null;
|
|
93
95
|
return this._withLock(async () => {
|
|
94
96
|
const state = this._read();
|
|
95
|
-
|
|
96
|
-
|
|
97
|
+
const consumers = state.consumers.filter(c => consumesJournal(c, journalId));
|
|
98
|
+
if (consumers.length === 0) return null;
|
|
99
|
+
return consumers.reduce((min, c) => Math.min(min, journalId === 'global' ? c.ack || 0 : (c.cursors || {})[journalId] || 0), Number.POSITIVE_INFINITY);
|
|
97
100
|
});
|
|
98
101
|
}
|
|
99
102
|
}
|
|
100
103
|
|
|
101
|
-
|
|
104
|
+
// Legacy consumers are global-only. An explicit wildcard protects journals
|
|
105
|
+
// created after registration too; an offline consumer is never expired.
|
|
106
|
+
function consumesJournal(consumer, journalId) {
|
|
107
|
+
const journals = consumer.meta && consumer.meta.journals;
|
|
108
|
+
return Array.isArray(journals) ? journals.includes('*') || journals.includes(journalId) : journalId === 'global';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { ConsumerRegistry, consumesJournal };
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { isValidRepoIdentityV1 } = require('./repo-context');
|
|
4
4
|
|
|
5
|
-
const SOURCES = ['claude-code', 'codex', 'opencode'];
|
|
5
|
+
const SOURCES = ['claude-code', 'codex', 'opencode', 'zcode'];
|
|
6
6
|
const IDENTITY_CONFIDENCE_LEVELS = ['exact', 'partial', 'unavailable'];
|
|
7
7
|
const CAPTURE_STATUSES = ['complete', 'partial', 'gap'];
|
|
8
8
|
const ROLES = ['user', 'assistant'];
|
package/src/bridge/core/lock.js
CHANGED
|
@@ -47,7 +47,7 @@ class CrossProcessLock {
|
|
|
47
47
|
try {
|
|
48
48
|
const st = fs.statSync(this.lockFile);
|
|
49
49
|
if (Date.now() - st.mtimeMs > this.staleMs) {
|
|
50
|
-
fs.
|
|
50
|
+
fs.unlinkSync(this.lockFile);
|
|
51
51
|
continue;
|
|
52
52
|
}
|
|
53
53
|
} catch (_) {
|
|
@@ -64,9 +64,11 @@ class CrossProcessLock {
|
|
|
64
64
|
if (!this._held) return;
|
|
65
65
|
this._held = false;
|
|
66
66
|
try {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
// A lock is one file, never a directory. unlink also avoids Node 24's
|
|
68
|
+
// Windows rm(force) no-op on some non-ASCII filenames.
|
|
69
|
+
fs.unlinkSync(this.lockFile);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (!error || error.code !== 'ENOENT') throw error;
|
|
70
72
|
}
|
|
71
73
|
}
|
|
72
74
|
|
package/src/server.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
5
6
|
const {
|
|
6
7
|
Journal,
|
|
7
8
|
ConversationQuery,
|
|
@@ -14,6 +15,7 @@ const {
|
|
|
14
15
|
projectJournalDir,
|
|
15
16
|
repoIdentityKey
|
|
16
17
|
} = require('./bridge');
|
|
18
|
+
const { createBridge } = require('./bridge');
|
|
17
19
|
const { pickFolder } = require('./pick-folder');
|
|
18
20
|
const { detectAgents } = require('./agent-detect');
|
|
19
21
|
const corePackage = require('../package.json');
|
|
@@ -74,11 +76,12 @@ function pickFolderEnabled() {
|
|
|
74
76
|
return process.env.BRIDGE_CONSOLE_PICK_FOLDER !== '0';
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
function createConsoleServer({ home }) {
|
|
79
|
+
function createConsoleServer({ home, runtimeToken }) {
|
|
78
80
|
if (typeof home !== 'string' || !home) {
|
|
79
81
|
throw new Error('createConsoleServer requires an explicit home directory');
|
|
80
82
|
}
|
|
81
83
|
const globalDir = globalJournalDir(home);
|
|
84
|
+
const bridge = createBridge({ homeDir: home });
|
|
82
85
|
const consolePageFile = path.join(__dirname, '..', 'console', 'index.html');
|
|
83
86
|
|
|
84
87
|
const journals = new Map();
|
|
@@ -283,13 +286,15 @@ function createConsoleServer({ home }) {
|
|
|
283
286
|
const journal = existingJournal(projectJournalDir(project));
|
|
284
287
|
if (!journal) return;
|
|
285
288
|
const sealDir = sealDirOf(project);
|
|
289
|
+
const minAck = await new ConsumerRegistry(home).getMinAck({ journalId: project.id });
|
|
290
|
+
const throughSequence = minAck === null ? Infinity : minAck;
|
|
286
291
|
try {
|
|
287
|
-
await commitProjection.reconcileTrim({ journal, sealDir });
|
|
292
|
+
await commitProjection.reconcileTrim({ journal, sealDir, throughSequence });
|
|
288
293
|
} catch (_) {
|
|
289
294
|
// best-effort: the next maintenance pass retries
|
|
290
295
|
}
|
|
291
296
|
try {
|
|
292
|
-
await commitProjection.sealUncommittedTail({ journal, sealDir });
|
|
297
|
+
await commitProjection.sealUncommittedTail({ journal, sealDir, throughSequence });
|
|
293
298
|
} catch (_) {
|
|
294
299
|
// best-effort
|
|
295
300
|
}
|
|
@@ -441,6 +446,50 @@ function createConsoleServer({ home }) {
|
|
|
441
446
|
|
|
442
447
|
async function handleApi(req, res, url) {
|
|
443
448
|
const route = `${req.method} ${url.pathname}`;
|
|
449
|
+
const journalId = url.searchParams.get('journalId') || 'global';
|
|
450
|
+
|
|
451
|
+
if (url.pathname.startsWith('/api/internal/')) {
|
|
452
|
+
const supplied = String(req.headers.authorization || '').replace(/^Bearer /, '');
|
|
453
|
+
const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress);
|
|
454
|
+
if (!local || req.headers.origin || !runtimeToken || Buffer.byteLength(runtimeToken) !== Buffer.byteLength(supplied) || !crypto.timingSafeEqual(Buffer.from(runtimeToken), Buffer.from(supplied))) {
|
|
455
|
+
sendJson(res, 403, { error: { code: 'TRUSTED_SERVICE_REQUIRED', message: 'Trusted local service required' } }); return;
|
|
456
|
+
}
|
|
457
|
+
if (route !== 'POST /api/internal/workbench-event') { sendJson(res, 404, { error: { code: 'NOT_FOUND' } }); return; }
|
|
458
|
+
try { sendJson(res, 200, await bridge.captureWorkbenchEvent(JSON.parse(await readBody(req)))); }
|
|
459
|
+
catch (error) { sendJson(res, 409, { error: { code: error.code || 'CAPTURE_INVALID', message: '工作台会话尚未写入,请检查项目接线。' } }); }
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (route === 'GET /api/journals') {
|
|
464
|
+
sendJson(res, 200, { journals: await bridge.listJournals() });
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (route === 'GET /api/journal/high-watermark') {
|
|
468
|
+
sendJson(res, 200, { journalId, sequence: await bridge.getHighWatermark({ journalId }) });
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (route === 'GET /api/journal/events') {
|
|
472
|
+
const from = Number(url.searchParams.get('fromSequence') || 1);
|
|
473
|
+
const limit = Number(url.searchParams.get('limit') || 100);
|
|
474
|
+
if (!Number.isSafeInteger(from) || from < 1 || !Number.isSafeInteger(limit) || limit < 1 || limit > 1000) {
|
|
475
|
+
sendJson(res, 400, { error: { code: 'INVALID_ARGUMENT', message: 'invalid journal range' } });
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
sendJson(res, 200, { journalId, events: await bridge.readEvents({ journalId, fromSequence: from, limit }) });
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (route === 'GET /api/consumers') {
|
|
482
|
+
sendJson(res, 200, { consumers: await bridge.listConsumers() });
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (route === 'POST /api/consumers/register' || route === 'POST /api/consumers/ack') {
|
|
486
|
+
const body = JSON.parse(await readBody(req));
|
|
487
|
+
const result = route.endsWith('/register')
|
|
488
|
+
? await bridge.registerConsumer(body.name, body.meta || {})
|
|
489
|
+
: await bridge.ackConsumerCursor(body.name, body.sequence, { journalId: body.journalId || 'global' });
|
|
490
|
+
sendJson(res, 200, result);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
444
493
|
|
|
445
494
|
if (route === 'GET /api/health') {
|
|
446
495
|
const journal = existingJournal(globalDir);
|