@sanqianx/ai-coding-event-bridge-console 0.5.1 → 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 +2 -5
- package/src/agent-detect.js +1 -1
- package/src/bin.js +28 -5
- package/src/bridge/bin/commit-boundary.js +64 -0
- package/src/bridge/connectors/capture-guard.js +24 -0
- package/src/bridge/connectors/claude-code/hook-entry.js +45 -0
- package/src/bridge/connectors/claude-code/notify.js +57 -0
- package/src/bridge/connectors/codex/cursor-store.js +83 -0
- package/src/bridge/connectors/codex/hook-entry.js +215 -0
- package/src/bridge/connectors/codex/session-parser.js +100 -0
- package/src/bridge/connectors/opencode/hook-entry.js +55 -0
- package/src/bridge/core/bridge.js +246 -0
- package/src/bridge/core/commit-projection.js +288 -0
- package/src/bridge/core/compaction.js +23 -0
- package/src/bridge/core/consumer-registry.js +111 -0
- package/src/bridge/core/event-schema.js +127 -0
- package/src/bridge/core/fs-utils.js +81 -0
- package/src/bridge/core/journal-router.js +39 -0
- package/src/bridge/core/journal.js +507 -0
- package/src/bridge/core/lock.js +96 -0
- package/src/bridge/core/normalizer.js +114 -0
- package/src/bridge/core/paths.js +12 -0
- package/src/bridge/core/project-registry.js +176 -0
- package/src/bridge/core/repo-context.js +188 -0
- package/src/bridge/core/runtime-home.js +188 -0
- package/src/bridge/core/semver.js +23 -0
- package/src/bridge/core/turn-identity.js +127 -0
- package/src/bridge/index.js +86 -0
- package/src/bridge/installers/claude-code/installer.js +181 -0
- package/src/bridge/installers/codex/installer.js +114 -0
- package/src/bridge/installers/opencode/installer.js +96 -0
- package/src/bridge/installers/zcode/installer.js +188 -0
- package/src/bridge/query/conversation-query.js +194 -0
- package/src/server.js +54 -5
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { validateAndNormalizeEvent } = require('../../core/event-schema');
|
|
6
|
+
const { resolveRepoContext } = require('../../core/repo-context');
|
|
7
|
+
const { journalFor } = require('../../core/journal-router');
|
|
8
|
+
const { CodexCursorStore } = require('./cursor-store');
|
|
9
|
+
const { parseSessionChunk, extractMessage, extractWorkspaceMeta } = require('./session-parser');
|
|
10
|
+
const { isCaptureDisabled, captureDisabledResult } = require('../capture-guard');
|
|
11
|
+
const { notifyConsumers } = require('../claude-code/notify');
|
|
12
|
+
const { CrossProcessLock } = require('../../core/lock');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Deterministic Codex capture.
|
|
16
|
+
*
|
|
17
|
+
* The notify payload is only a wake-up signal; it never carries the workspace.
|
|
18
|
+
* The real workspace comes from the session's authoritative rollout metadata
|
|
19
|
+
* (session_meta.payload.cwd / turn_context.payload.cwd) — NEVER from the
|
|
20
|
+
* session file's location, which is Codex runtime storage. Bytes are consumed
|
|
21
|
+
* incrementally per session; a partial trailing line stays unread until the
|
|
22
|
+
* next wake-up. When no authoritative cwd can be parsed, a
|
|
23
|
+
* codex-workspace-unresolved gap is recorded and the event is never bound to
|
|
24
|
+
* any project.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
function recordGap(home, reason, meta = {}) {
|
|
28
|
+
try {
|
|
29
|
+
const gapsFile = path.join(home, 'journal', 'capture-gaps.jsonl');
|
|
30
|
+
fs.mkdirSync(path.dirname(gapsFile), { recursive: true });
|
|
31
|
+
fs.appendFileSync(
|
|
32
|
+
gapsFile,
|
|
33
|
+
`${JSON.stringify({ schema: 'bridge-capture-gap/v1', source: 'codex', reason, ...meta, at: new Date().toISOString() })}\n`
|
|
34
|
+
);
|
|
35
|
+
} catch (_) {
|
|
36
|
+
// Gap persistence is best effort; the decision to not guess stands regardless.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizedPath(filePath) {
|
|
41
|
+
const resolved = path.resolve(filePath);
|
|
42
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function fileKey(sessionsRoot, filePath) {
|
|
46
|
+
return path.relative(path.resolve(sessionsRoot), path.resolve(filePath)).split(path.sep).join('/');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function findSessionFiles(sessionsRoot, sessionId) {
|
|
50
|
+
// Deterministic discovery: Codex rollout files embed the session id in the
|
|
51
|
+
// file name; we search by exact id, never by mtime.
|
|
52
|
+
const stack = [sessionsRoot];
|
|
53
|
+
const matches = [];
|
|
54
|
+
while (stack.length) {
|
|
55
|
+
const dir = stack.pop();
|
|
56
|
+
let entries;
|
|
57
|
+
try {
|
|
58
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
59
|
+
} catch (_) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
const full = path.join(dir, entry.name);
|
|
64
|
+
if (entry.isDirectory()) {
|
|
65
|
+
stack.push(full);
|
|
66
|
+
} else if (entry.isFile() && entry.name.endsWith('.jsonl') && entry.name.includes(sessionId)) {
|
|
67
|
+
matches.push(full);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return matches.sort((left, right) => fileKey(sessionsRoot, left).localeCompare(fileKey(sessionsRoot, right)));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function findSessionFile(sessionsRoot, sessionId) {
|
|
75
|
+
const files = await findSessionFiles(sessionsRoot, sessionId);
|
|
76
|
+
return files[0] || null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function emptyFileState(filePath) {
|
|
80
|
+
return {
|
|
81
|
+
filePath,
|
|
82
|
+
byteOffset: 0,
|
|
83
|
+
lastRecordKey: null,
|
|
84
|
+
activeCwd: null,
|
|
85
|
+
repoIdentity: null,
|
|
86
|
+
projectPath: null,
|
|
87
|
+
branch: null,
|
|
88
|
+
headAtCapture: null
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function captureSessionUnlocked({ home, sessionsRoot, sessionId, cursorStore }) {
|
|
93
|
+
const cursor = cursorStore.read(sessionId);
|
|
94
|
+
const filePaths = await findSessionFiles(sessionsRoot, sessionId);
|
|
95
|
+
if (!filePaths.length) {
|
|
96
|
+
recordGap(home, 'session-file-not-found', { sessionId });
|
|
97
|
+
return { status: 'gap', reason: 'session-file-not-found' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (cursor.legacyFile) {
|
|
101
|
+
const legacyMatch = filePaths.find(candidate => normalizedPath(candidate) === normalizedPath(cursor.legacyFile.filePath));
|
|
102
|
+
if (legacyMatch) {
|
|
103
|
+
cursor.files[fileKey(sessionsRoot, legacyMatch)] = { ...cursor.legacyFile, filePath: legacyMatch };
|
|
104
|
+
}
|
|
105
|
+
delete cursor.legacyFile;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let captured = 0;
|
|
109
|
+
let completeBytes = 0;
|
|
110
|
+
|
|
111
|
+
for (const filePath of filePaths) {
|
|
112
|
+
const key = fileKey(sessionsRoot, filePath);
|
|
113
|
+
const state = cursor.files[key] || emptyFileState(filePath);
|
|
114
|
+
state.filePath = filePath;
|
|
115
|
+
const fromOffset = state.byteOffset || 0;
|
|
116
|
+
const parsed = parseSessionChunk(filePath, fromOffset);
|
|
117
|
+
let unresolvedGapRecorded = false;
|
|
118
|
+
|
|
119
|
+
for (let index = 0; index < parsed.records.length; index += 1) {
|
|
120
|
+
const record = parsed.records[index];
|
|
121
|
+
const meta = extractWorkspaceMeta(record);
|
|
122
|
+
if (meta) {
|
|
123
|
+
if (meta.cwd && meta.cwd !== state.activeCwd) {
|
|
124
|
+
// Authoritative workspace switch (session start or turn_context change).
|
|
125
|
+
const repo = await resolveRepoContext(meta.cwd);
|
|
126
|
+
state.activeCwd = meta.cwd;
|
|
127
|
+
state.repoIdentity = repo.repoIdentity;
|
|
128
|
+
state.projectPath = repo.projectPath;
|
|
129
|
+
state.branch = repo.branch;
|
|
130
|
+
state.headAtCapture = repo.headAtCapture;
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const message = extractMessage(record);
|
|
135
|
+
if (!message) continue;
|
|
136
|
+
if (!state.repoIdentity && !unresolvedGapRecorded) {
|
|
137
|
+
recordGap(home, 'codex-workspace-unresolved', { sessionId, rolloutFile: key });
|
|
138
|
+
unresolvedGapRecorded = true;
|
|
139
|
+
}
|
|
140
|
+
const byteOffset = parsed.recordOffsets[index];
|
|
141
|
+
const recordKey = `${key}:${byteOffset}`;
|
|
142
|
+
const event = validateAndNormalizeEvent({
|
|
143
|
+
source: 'codex',
|
|
144
|
+
eventType: message.role === 'user' ? 'user_prompt' : 'assistant_response',
|
|
145
|
+
role: message.role,
|
|
146
|
+
content: message.text,
|
|
147
|
+
sessionId,
|
|
148
|
+
turnId: message.turnId,
|
|
149
|
+
repoIdentity: state.repoIdentity,
|
|
150
|
+
projectPath: state.projectPath,
|
|
151
|
+
branch: state.branch,
|
|
152
|
+
headAtCapture: state.headAtCapture,
|
|
153
|
+
identityConfidence: message.turnId ? 'exact' : 'partial',
|
|
154
|
+
captureStatus: 'complete',
|
|
155
|
+
rawEventType: record.type || 'message',
|
|
156
|
+
eventKey: `codex:${sessionId}:${recordKey}`,
|
|
157
|
+
meta: { recordKey, rolloutFile: key, byteOffset, phase: message.phase }
|
|
158
|
+
});
|
|
159
|
+
// The workspace can switch mid-session (turn_context), so the journal is
|
|
160
|
+
// routed per record from the authoritative identity, never per session.
|
|
161
|
+
const journal = journalFor(home, state.repoIdentity);
|
|
162
|
+
const appended = await journal.appendConversationEvent(event);
|
|
163
|
+
state.lastRecordKey = recordKey;
|
|
164
|
+
if (!appended.duplicate) captured += 1;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
state.byteOffset = fromOffset + parsed.completeBytes;
|
|
168
|
+
cursor.files[key] = state;
|
|
169
|
+
completeBytes += parsed.completeBytes;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
cursorStore.write(sessionId, cursor);
|
|
173
|
+
return { status: 'captured', captured, files: filePaths.length, completeBytes };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function captureSession({ home, sessionsRoot, sessionId }) {
|
|
177
|
+
const cursorStore = new CodexCursorStore(home);
|
|
178
|
+
const safe = String(sessionId).replace(/[^A-Za-z0-9._-]/g, '_');
|
|
179
|
+
const lock = new CrossProcessLock(path.join(home, 'cursors', 'codex', `${safe}.capture.lock`));
|
|
180
|
+
return lock.withLock(() => captureSessionUnlocked({ home, sessionsRoot, sessionId, cursorStore }));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function main({ home, payload }) {
|
|
184
|
+
if (isCaptureDisabled(process.env, payload)) {
|
|
185
|
+
return captureDisabledResult();
|
|
186
|
+
}
|
|
187
|
+
if (!payload || typeof payload !== 'object') {
|
|
188
|
+
return { status: 'ignored' };
|
|
189
|
+
}
|
|
190
|
+
const sessionId = payload.session_id || payload.sessionId || null;
|
|
191
|
+
if (!sessionId || typeof sessionId !== 'string') {
|
|
192
|
+
// Ambiguous notify: never fall back to the newest session by mtime.
|
|
193
|
+
recordGap(home, 'notify-session-unresolved', { keys: Object.keys(payload).slice(0, 8) });
|
|
194
|
+
return { status: 'gap', reason: 'notify-session-unresolved' };
|
|
195
|
+
}
|
|
196
|
+
const sessionsRoot =
|
|
197
|
+
payload.sessions_root ||
|
|
198
|
+
(process.env.CODEX_SESSIONS_ROOT) ||
|
|
199
|
+
path.join(home, 'fixtures', 'codex-sessions');
|
|
200
|
+
const result = await captureSession({
|
|
201
|
+
home,
|
|
202
|
+
sessionsRoot,
|
|
203
|
+
sessionId
|
|
204
|
+
});
|
|
205
|
+
if (result.status === 'captured' && result.captured > 0) {
|
|
206
|
+
await notifyConsumers(home, { source: 'codex', sessionId });
|
|
207
|
+
}
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function mainFailOpen({ home, payload }) {
|
|
212
|
+
return main({ home, payload }).catch(() => ({ status: 'fail-open' }));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = { main, mainFailOpen, captureSession, findSessionFile, findSessionFiles, recordGap };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Incremental Codex session JSONL parser. Reads only appended bytes after the
|
|
7
|
+
* cursor offset, tolerates a partial trailing line, and classifies records
|
|
8
|
+
* into messages and workspace metadata. Never re-reads the whole file and
|
|
9
|
+
* never selects sessions by mtime — the caller identifies the session file
|
|
10
|
+
* deterministically.
|
|
11
|
+
*/
|
|
12
|
+
function parseSessionChunk(filePath, fromOffset) {
|
|
13
|
+
const size = fs.statSync(filePath).size;
|
|
14
|
+
if (size <= fromOffset) return { records: [], recordOffsets: [], completeBytes: 0, size };
|
|
15
|
+
const fd = fs.openSync(filePath, 'r');
|
|
16
|
+
let raw;
|
|
17
|
+
try {
|
|
18
|
+
const buf = Buffer.alloc(size - fromOffset);
|
|
19
|
+
const read = fs.readSync(fd, buf, 0, buf.length, fromOffset);
|
|
20
|
+
raw = buf.toString('utf8', 0, read);
|
|
21
|
+
} finally {
|
|
22
|
+
fs.closeSync(fd);
|
|
23
|
+
}
|
|
24
|
+
const records = [];
|
|
25
|
+
const recordOffsets = [];
|
|
26
|
+
let completeBytes = 0;
|
|
27
|
+
const chunks = raw.split('\n');
|
|
28
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
29
|
+
const line = chunks[i];
|
|
30
|
+
if (i === chunks.length - 1) break; // trailing bytes without newline = partial
|
|
31
|
+
if (line.length === 0) {
|
|
32
|
+
completeBytes += 1;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(line);
|
|
38
|
+
} catch (_) {
|
|
39
|
+
parsed = null;
|
|
40
|
+
}
|
|
41
|
+
if (parsed && typeof parsed === 'object') {
|
|
42
|
+
records.push(parsed);
|
|
43
|
+
recordOffsets.push(fromOffset + completeBytes);
|
|
44
|
+
}
|
|
45
|
+
completeBytes += Buffer.byteLength(line, 'utf8') + 1;
|
|
46
|
+
}
|
|
47
|
+
return { records, recordOffsets, completeBytes, size };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Authoritative workspace metadata from Codex rollout records:
|
|
52
|
+
* session_meta.payload.cwd and turn_context.payload.cwd. A verified
|
|
53
|
+
* turn_context cwd may update the active workspace for subsequent events.
|
|
54
|
+
*/
|
|
55
|
+
function extractWorkspaceMeta(record) {
|
|
56
|
+
if (!record || typeof record !== 'object') return null;
|
|
57
|
+
const payload = record.payload && typeof record.payload === 'object' ? record.payload : null;
|
|
58
|
+
if (record.type === 'session_meta' || (payload && payload.type === 'session_meta')) {
|
|
59
|
+
const cwd = payload && typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : null;
|
|
60
|
+
return { kind: 'session_meta', cwd };
|
|
61
|
+
}
|
|
62
|
+
if (record.type === 'turn_context' || (payload && payload.type === 'turn_context')) {
|
|
63
|
+
const cwd = payload && typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : null;
|
|
64
|
+
return { kind: 'turn_context', cwd };
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function extractMessage(record) {
|
|
70
|
+
if (!record || typeof record !== 'object') return null;
|
|
71
|
+
const payload = record.payload && typeof record.payload === 'object' ? record.payload : record;
|
|
72
|
+
const role = payload.role || record.role;
|
|
73
|
+
if (role !== 'user' && role !== 'assistant') return null;
|
|
74
|
+
const text = collectText(payload.content !== undefined ? payload.content : record.content);
|
|
75
|
+
if (text === null) return null;
|
|
76
|
+
const passthrough = payload.internal_chat_message_metadata_passthrough;
|
|
77
|
+
const nestedTurnId = passthrough && typeof passthrough === 'object' ? passthrough.turn_id : null;
|
|
78
|
+
return {
|
|
79
|
+
role,
|
|
80
|
+
text,
|
|
81
|
+
turnId: payload.turn_id || nestedTurnId || record.turn_id || null,
|
|
82
|
+
phase: typeof payload.phase === 'string' && payload.phase ? payload.phase : null
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function collectText(content) {
|
|
87
|
+
if (typeof content === 'string') return content;
|
|
88
|
+
if (Array.isArray(content)) {
|
|
89
|
+
const parts = [];
|
|
90
|
+
for (const item of content) {
|
|
91
|
+
if (typeof item === 'string') parts.push(item);
|
|
92
|
+
else if (item && typeof item === 'object' && typeof item.text === 'string') parts.push(item.text);
|
|
93
|
+
}
|
|
94
|
+
if (parts.length) return parts.join('\n');
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { parseSessionChunk, extractMessage, extractWorkspaceMeta };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { validateAndNormalizeEvent } = require('../../core/event-schema');
|
|
4
|
+
const { resolveRepoContext } = require('../../core/repo-context');
|
|
5
|
+
const { journalFor } = require('../../core/journal-router');
|
|
6
|
+
const { isCaptureDisabled, captureDisabledResult } = require('../capture-guard');
|
|
7
|
+
const { notifyConsumers } = require('../claude-code/notify');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* OpenCode plugin capture. The plugin ships the native event to the stable
|
|
11
|
+
* shim; capture is a local durable append first via the canonical
|
|
12
|
+
* conversation path (Bridge-owned turn identity). There is no HTTP endpoint
|
|
13
|
+
* dependency — consumer wake-up uses the generic best-effort notify channel
|
|
14
|
+
* only. Capture-disable is checked before any repo/journal work.
|
|
15
|
+
*/
|
|
16
|
+
async function main({ home, payload }) {
|
|
17
|
+
if (isCaptureDisabled(process.env, payload)) {
|
|
18
|
+
return captureDisabledResult();
|
|
19
|
+
}
|
|
20
|
+
if (!payload || typeof payload !== 'object') {
|
|
21
|
+
return { status: 'ignored' };
|
|
22
|
+
}
|
|
23
|
+
if (payload.type !== 'user' && payload.type !== 'assistant') {
|
|
24
|
+
// Tool/file/todo lifecycle events stay out of the conversation truth.
|
|
25
|
+
return { status: 'ignored' };
|
|
26
|
+
}
|
|
27
|
+
const cwd = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
|
|
28
|
+
const repo = await resolveRepoContext(cwd);
|
|
29
|
+
const journal = journalFor(home, repo.repoIdentity);
|
|
30
|
+
const isUser = payload.type === 'user';
|
|
31
|
+
const event = validateAndNormalizeEvent({
|
|
32
|
+
source: 'opencode',
|
|
33
|
+
eventType: isUser ? 'user_prompt' : 'assistant_response',
|
|
34
|
+
role: isUser ? 'user' : 'assistant',
|
|
35
|
+
content: typeof payload.text === 'string' ? payload.text : null,
|
|
36
|
+
sessionId: payload.sessionId || payload.session_id || null,
|
|
37
|
+
turnId: payload.turnId || null,
|
|
38
|
+
repoIdentity: repo.repoIdentity,
|
|
39
|
+
projectPath: repo.projectPath,
|
|
40
|
+
branch: repo.branch,
|
|
41
|
+
headAtCapture: repo.headAtCapture,
|
|
42
|
+
identityConfidence: payload.sessionId ? (payload.turnId ? 'exact' : 'partial') : 'unavailable',
|
|
43
|
+
captureStatus: typeof payload.text === 'string' ? 'complete' : 'partial',
|
|
44
|
+
rawEventType: payload.type
|
|
45
|
+
});
|
|
46
|
+
const appended = await journal.appendConversationEvent(event);
|
|
47
|
+
await notifyConsumers(home, { source: 'opencode', sequence: appended.sequence, eventId: appended.eventId });
|
|
48
|
+
return { status: 'captured', sequence: appended.sequence, turnId: appended.turnId };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function mainFailOpen({ home, payload }) {
|
|
52
|
+
return main({ home, payload }).catch(() => ({ status: 'fail-open' }));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { main, mainFailOpen };
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { bridgeHome } = require('./paths');
|
|
6
|
+
const { Journal } = require('./journal');
|
|
7
|
+
const { ConsumerRegistry } = require('./consumer-registry');
|
|
8
|
+
const { compactJournal } = require('./compaction');
|
|
9
|
+
const { journalFor, globalJournalDir } = require('./journal-router');
|
|
10
|
+
const projectRegistry = require('./project-registry');
|
|
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');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Stable host-facing facade over the Bridge runtime. Hosts (Project-Knowledge,
|
|
18
|
+
* DevTask-Radar, ...) must talk to this facade only — never to Journal
|
|
19
|
+
* internals or Bridge filesystem layout.
|
|
20
|
+
*/
|
|
21
|
+
function createBridge({ homeDir } = {}) {
|
|
22
|
+
const home = bridgeHome(homeDir);
|
|
23
|
+
// Lazy internals: constructing a facade must not touch the filesystem, so
|
|
24
|
+
// merely loading a Bridge (e.g. in a host test) creates no runtime home.
|
|
25
|
+
let journal = null;
|
|
26
|
+
let registry = null;
|
|
27
|
+
const getJournal = () => {
|
|
28
|
+
if (!journal) journal = new Journal(path.join(home, 'journal'));
|
|
29
|
+
return journal;
|
|
30
|
+
};
|
|
31
|
+
const getRegistry = () => {
|
|
32
|
+
if (!registry) registry = new ConsumerRegistry(home);
|
|
33
|
+
return registry;
|
|
34
|
+
};
|
|
35
|
+
// Pure reads on a machine where no Bridge home exists yet must not
|
|
36
|
+
// materialize one: status queries stay side-effect free.
|
|
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
|
+
};
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
homeDir: home,
|
|
47
|
+
|
|
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' } = {}) {
|
|
53
|
+
if (!homeExists()) return 0;
|
|
54
|
+
const bounds = await selectJournal(journalId).getBounds();
|
|
55
|
+
return bounds.lastSequence;
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async readEvents(options = {}) {
|
|
59
|
+
return selectJournal(options.journalId).readEvents(options);
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
// Host-side injection for tests and explicit workflows; automatic capture
|
|
63
|
+
// flows through the connectors' canonical append path.
|
|
64
|
+
async appendConversationEvent(event) {
|
|
65
|
+
return getJournal().appendConversationEvent(event);
|
|
66
|
+
},
|
|
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
|
+
|
|
92
|
+
async appendCommitBoundary({ projectId, repoIdentity, commitSha, parentShas, parents, branch, subject, committedAt, operationId, meta } = {}) {
|
|
93
|
+
if (!repoIdentity) {
|
|
94
|
+
throw new Error('appendCommitBoundary requires repoIdentity');
|
|
95
|
+
}
|
|
96
|
+
// Boundaries live in the same journal as the repo's conversation events
|
|
97
|
+
// (openTurnIdsAtCommit is computed from that journal's open-turn state).
|
|
98
|
+
const journal = journalFor(home, repoIdentity);
|
|
99
|
+
const result = await journal.appendCommitBoundary(repoIdentity, {
|
|
100
|
+
commitSha,
|
|
101
|
+
parents: parents !== undefined ? parents : parentShas,
|
|
102
|
+
branch,
|
|
103
|
+
subject,
|
|
104
|
+
committedAt,
|
|
105
|
+
projectId,
|
|
106
|
+
operationId,
|
|
107
|
+
meta
|
|
108
|
+
});
|
|
109
|
+
const routed = journal.journalDir !== globalJournalDir(home);
|
|
110
|
+
let bridgeCursorAtCommit = result.sequence;
|
|
111
|
+
if (routed) {
|
|
112
|
+
// Routed to a registered project's journal: the boundary is not in the
|
|
113
|
+
// global journal, so the global consumer watermark is unchanged. Keep
|
|
114
|
+
// the returned cursor conservative (never past unseen global content).
|
|
115
|
+
const bounds = await getJournal().getBounds();
|
|
116
|
+
bridgeCursorAtCommit = bounds.lastSequence;
|
|
117
|
+
}
|
|
118
|
+
// A routed boundary means a registered project just committed: freeze
|
|
119
|
+
// that commit's conversations into <store>/commits/<sha>.md. Projection
|
|
120
|
+
// failures are reported, never thrown — the journal remains the only
|
|
121
|
+
// source of truth and the boundary is already durable.
|
|
122
|
+
let projection = { sealed: false, turnCount: 0, reason: routed ? 'project-not-registered' : 'global-journal' };
|
|
123
|
+
if (routed) {
|
|
124
|
+
try {
|
|
125
|
+
const project = projectRegistry.findProject(home, repoIdentity);
|
|
126
|
+
if (project) {
|
|
127
|
+
const sealDir = sealDirFor(project.store);
|
|
128
|
+
projection = await sealCommitConversations({
|
|
129
|
+
journal,
|
|
130
|
+
sealDir,
|
|
131
|
+
boundary: {
|
|
132
|
+
commitSha,
|
|
133
|
+
branch,
|
|
134
|
+
subject,
|
|
135
|
+
committedAt,
|
|
136
|
+
sequence: result.sequence,
|
|
137
|
+
openTurnIdsAtCommit: result.openTurnIdsAtCommit,
|
|
138
|
+
previousRepoBoundarySequence: result.previousRepoBoundarySequence
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
// The journal is a conveyor: once the archive copy verifiably
|
|
142
|
+
// exists on disk, drop the sealed prefix. On any miss the prefix
|
|
143
|
+
// stays and the next boundary's seal window covers it (self-heal).
|
|
144
|
+
if (projection.sealed || (projection.reason === 'already-sealed' && fs.existsSync(projection.path))) {
|
|
145
|
+
const minAck = await getRegistry().getMinAck({ journalId: project.id });
|
|
146
|
+
const trim = await journal.trimThrough(minAck === null ? result.sequence : Math.min(result.sequence, minAck));
|
|
147
|
+
projection.trimmedThrough = trim.trimmedThrough;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch (err) {
|
|
151
|
+
projection = { sealed: false, turnCount: 0, error: err && err.message ? err.message : String(err) };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
sequence: result.sequence,
|
|
156
|
+
journalId: routed ? projectRegistry.findProject(home, repoIdentity).id : 'global',
|
|
157
|
+
bridgeCursorAtCommit,
|
|
158
|
+
openTurnIdsAtCommit: result.openTurnIdsAtCommit,
|
|
159
|
+
previousRepoBoundarySequence: result.previousRepoBoundarySequence,
|
|
160
|
+
committedAt: committedAt || new Date().toISOString(),
|
|
161
|
+
projection
|
|
162
|
+
};
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
async registerConsumer(name, meta = {}) {
|
|
166
|
+
return getRegistry().registerConsumer(name, meta);
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
async getConsumer(name) {
|
|
170
|
+
if (!homeExists()) return null;
|
|
171
|
+
const consumers = await getRegistry().getConsumers();
|
|
172
|
+
return consumers.find((consumer) => consumer.name === name) || null;
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
async listConsumers() {
|
|
176
|
+
if (!homeExists()) return [];
|
|
177
|
+
return getRegistry().getConsumers();
|
|
178
|
+
},
|
|
179
|
+
|
|
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);
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
async unregisterConsumer(name) {
|
|
187
|
+
return getRegistry().unregisterConsumer(name);
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
async compact({ throughSequence, journalId = 'global' } = {}) {
|
|
191
|
+
return compactJournal(selectJournal(journalId), getRegistry(), { throughSequence, journalId });
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
async getHealth() {
|
|
195
|
+
if (!homeExists()) {
|
|
196
|
+
return {
|
|
197
|
+
journalSizeBytes: 0,
|
|
198
|
+
firstSequence: 0,
|
|
199
|
+
lastSequence: 0,
|
|
200
|
+
minConsumerAck: null,
|
|
201
|
+
oldestUnackedAt: null,
|
|
202
|
+
consumers: []
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const liveJournal = getJournal();
|
|
206
|
+
const [bounds, consumers, minConsumerAck] = await Promise.all([
|
|
207
|
+
liveJournal.getBounds(),
|
|
208
|
+
getRegistry().getConsumers(),
|
|
209
|
+
getRegistry().getMinAck()
|
|
210
|
+
]);
|
|
211
|
+
let journalSizeBytes = 0;
|
|
212
|
+
try {
|
|
213
|
+
journalSizeBytes = fs.statSync(liveJournal.eventsFile).size;
|
|
214
|
+
} catch (_) {
|
|
215
|
+
journalSizeBytes = 0;
|
|
216
|
+
}
|
|
217
|
+
let oldestUnackedAt = null;
|
|
218
|
+
if (minConsumerAck !== null && bounds.lastSequence > minConsumerAck) {
|
|
219
|
+
try {
|
|
220
|
+
const pending = await liveJournal.readEvents({ fromSequence: minConsumerAck + 1, toSequence: minConsumerAck + 1, limit: 1 });
|
|
221
|
+
if (pending.length) oldestUnackedAt = pending[0].capturedAt || null;
|
|
222
|
+
} catch (_) {
|
|
223
|
+
oldestUnackedAt = null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
journalSizeBytes,
|
|
228
|
+
firstSequence: bounds.firstSequence,
|
|
229
|
+
lastSequence: bounds.lastSequence,
|
|
230
|
+
minConsumerAck,
|
|
231
|
+
oldestUnackedAt,
|
|
232
|
+
consumers: consumers.map((consumer) => ({
|
|
233
|
+
name: consumer.name,
|
|
234
|
+
ack: consumer.ack,
|
|
235
|
+
lastSeenAt: consumer.lastSeenAt || null
|
|
236
|
+
}))
|
|
237
|
+
};
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
// Introspection helpers for diagnostics; not part of the frozen contract.
|
|
241
|
+
get _journal() { return journal; },
|
|
242
|
+
get _registry() { return registry; }
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
module.exports = { createBridge };
|