@sanqianx/ai-coding-event-bridge-console 0.7.1 → 0.7.3
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/LICENSE +21 -21
- package/README.md +81 -74
- package/console/index.html +953 -948
- package/package.json +26 -26
- package/src/agent-detect.js +128 -128
- package/src/bin.js +80 -80
- package/src/bridge/bin/commit-boundary.js +64 -64
- package/src/bridge/connectors/capture-guard.js +24 -24
- package/src/bridge/connectors/claude-code/hook-entry.js +45 -45
- package/src/bridge/connectors/claude-code/notify.js +57 -57
- package/src/bridge/connectors/codex/cursor-store.js +83 -83
- package/src/bridge/connectors/codex/hook-entry.js +215 -215
- package/src/bridge/connectors/codex/session-parser.js +100 -100
- package/src/bridge/connectors/opencode/hook-entry.js +55 -55
- package/src/bridge/core/bridge.js +249 -246
- package/src/bridge/core/commit-projection.js +288 -288
- package/src/bridge/core/compaction.js +23 -23
- package/src/bridge/core/consumer-registry.js +111 -111
- package/src/bridge/core/event-schema.js +127 -127
- package/src/bridge/core/fs-utils.js +81 -81
- package/src/bridge/core/journal-router.js +39 -39
- package/src/bridge/core/journal.js +507 -507
- package/src/bridge/core/lock.js +96 -96
- package/src/bridge/core/normalizer.js +114 -114
- package/src/bridge/core/paths.js +12 -12
- package/src/bridge/core/project-registry.js +176 -176
- package/src/bridge/core/repo-context.js +191 -191
- package/src/bridge/core/runtime-home.js +189 -188
- package/src/bridge/core/semver.js +23 -23
- package/src/bridge/core/turn-identity.js +127 -127
- package/src/bridge/index.js +86 -86
- package/src/bridge/installers/claude-code/installer.js +181 -181
- package/src/bridge/installers/codex/installer.js +114 -114
- package/src/bridge/installers/opencode/installer.js +96 -96
- package/src/bridge/installers/zcode/installer.js +200 -188
- package/src/bridge/query/conversation-query.js +194 -194
- package/src/server.js +825 -825
|
@@ -1,55 +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 };
|
|
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 };
|
|
@@ -1,246 +1,249 @@
|
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
|
|
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, comparisonForm, realpathBestEffort } = 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
|
+
// Use the identity resolver's native realpath for Windows 8.3 aliases.
|
|
78
|
+
const registeredPath = project && realpathBestEffort(project.path);
|
|
79
|
+
const inputPath = realpathBestEffort(input.projectPath);
|
|
80
|
+
if (!registeredPath || !inputPath || comparisonForm(registeredPath) !== comparisonForm(inputPath)) {
|
|
81
|
+
throw Object.assign(new Error('Workbench project is not registered'), { code: 'PROJECT_NOT_REGISTERED' });
|
|
82
|
+
}
|
|
83
|
+
const event = validateAndNormalizeEvent({
|
|
84
|
+
source: input.source, eventType: input.eventType, role: input.eventType === 'user_prompt' ? 'user' : input.eventType === 'assistant_response' ? 'assistant' : null,
|
|
85
|
+
content: input.eventType === 'session_end' ? undefined : input.content, sessionId: input.sessionId, turnId: input.turnId, eventKey: input.eventKey,
|
|
86
|
+
repoIdentity: repo.repoIdentity, projectPath: repo.projectPath, branch: repo.branch, headAtCapture: repo.headAtCapture,
|
|
87
|
+
capturedAt: typeof input.capturedAt === 'string' && !Number.isNaN(Date.parse(input.capturedAt)) ? input.capturedAt : new Date().toISOString(),
|
|
88
|
+
identityConfidence: 'exact', captureStatus: 'complete', meta: { origin: 'claude-ai-workbench', outcome: input.outcome === 'failed' ? 'failed' : 'completed' },
|
|
89
|
+
});
|
|
90
|
+
const appended = await journalFor(home, repo.repoIdentity).appendConversationEvent(event);
|
|
91
|
+
await notifyConsumers(home, { sequence: appended.sequence, eventId: appended.eventId });
|
|
92
|
+
return { status: 'captured', ...appended };
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
async appendCommitBoundary({ projectId, repoIdentity, commitSha, parentShas, parents, branch, subject, committedAt, operationId, meta } = {}) {
|
|
96
|
+
if (!repoIdentity) {
|
|
97
|
+
throw new Error('appendCommitBoundary requires repoIdentity');
|
|
98
|
+
}
|
|
99
|
+
// Boundaries live in the same journal as the repo's conversation events
|
|
100
|
+
// (openTurnIdsAtCommit is computed from that journal's open-turn state).
|
|
101
|
+
const journal = journalFor(home, repoIdentity);
|
|
102
|
+
const result = await journal.appendCommitBoundary(repoIdentity, {
|
|
103
|
+
commitSha,
|
|
104
|
+
parents: parents !== undefined ? parents : parentShas,
|
|
105
|
+
branch,
|
|
106
|
+
subject,
|
|
107
|
+
committedAt,
|
|
108
|
+
projectId,
|
|
109
|
+
operationId,
|
|
110
|
+
meta
|
|
111
|
+
});
|
|
112
|
+
const routed = journal.journalDir !== globalJournalDir(home);
|
|
113
|
+
let bridgeCursorAtCommit = result.sequence;
|
|
114
|
+
if (routed) {
|
|
115
|
+
// Routed to a registered project's journal: the boundary is not in the
|
|
116
|
+
// global journal, so the global consumer watermark is unchanged. Keep
|
|
117
|
+
// the returned cursor conservative (never past unseen global content).
|
|
118
|
+
const bounds = await getJournal().getBounds();
|
|
119
|
+
bridgeCursorAtCommit = bounds.lastSequence;
|
|
120
|
+
}
|
|
121
|
+
// A routed boundary means a registered project just committed: freeze
|
|
122
|
+
// that commit's conversations into <store>/commits/<sha>.md. Projection
|
|
123
|
+
// failures are reported, never thrown — the journal remains the only
|
|
124
|
+
// source of truth and the boundary is already durable.
|
|
125
|
+
let projection = { sealed: false, turnCount: 0, reason: routed ? 'project-not-registered' : 'global-journal' };
|
|
126
|
+
if (routed) {
|
|
127
|
+
try {
|
|
128
|
+
const project = projectRegistry.findProject(home, repoIdentity);
|
|
129
|
+
if (project) {
|
|
130
|
+
const sealDir = sealDirFor(project.store);
|
|
131
|
+
projection = await sealCommitConversations({
|
|
132
|
+
journal,
|
|
133
|
+
sealDir,
|
|
134
|
+
boundary: {
|
|
135
|
+
commitSha,
|
|
136
|
+
branch,
|
|
137
|
+
subject,
|
|
138
|
+
committedAt,
|
|
139
|
+
sequence: result.sequence,
|
|
140
|
+
openTurnIdsAtCommit: result.openTurnIdsAtCommit,
|
|
141
|
+
previousRepoBoundarySequence: result.previousRepoBoundarySequence
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
// The journal is a conveyor: once the archive copy verifiably
|
|
145
|
+
// exists on disk, drop the sealed prefix. On any miss the prefix
|
|
146
|
+
// stays and the next boundary's seal window covers it (self-heal).
|
|
147
|
+
if (projection.sealed || (projection.reason === 'already-sealed' && fs.existsSync(projection.path))) {
|
|
148
|
+
const minAck = await getRegistry().getMinAck({ journalId: project.id });
|
|
149
|
+
const trim = await journal.trimThrough(minAck === null ? result.sequence : Math.min(result.sequence, minAck));
|
|
150
|
+
projection.trimmedThrough = trim.trimmedThrough;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch (err) {
|
|
154
|
+
projection = { sealed: false, turnCount: 0, error: err && err.message ? err.message : String(err) };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
sequence: result.sequence,
|
|
159
|
+
journalId: routed ? projectRegistry.findProject(home, repoIdentity).id : 'global',
|
|
160
|
+
bridgeCursorAtCommit,
|
|
161
|
+
openTurnIdsAtCommit: result.openTurnIdsAtCommit,
|
|
162
|
+
previousRepoBoundarySequence: result.previousRepoBoundarySequence,
|
|
163
|
+
committedAt: committedAt || new Date().toISOString(),
|
|
164
|
+
projection
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
async registerConsumer(name, meta = {}) {
|
|
169
|
+
return getRegistry().registerConsumer(name, meta);
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
async getConsumer(name) {
|
|
173
|
+
if (!homeExists()) return null;
|
|
174
|
+
const consumers = await getRegistry().getConsumers();
|
|
175
|
+
return consumers.find((consumer) => consumer.name === name) || null;
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
async listConsumers() {
|
|
179
|
+
if (!homeExists()) return [];
|
|
180
|
+
return getRegistry().getConsumers();
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async ackConsumerCursor(name, sequence, options = {}) {
|
|
184
|
+
const high = await this.getHighWatermark(options);
|
|
185
|
+
if (sequence > high) throw Object.assign(new Error('ack exceeds journal high watermark'), { code: 'ACK_OUT_OF_RANGE' });
|
|
186
|
+
return getRegistry().ackConsumerCursor(name, sequence, options);
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
async unregisterConsumer(name) {
|
|
190
|
+
return getRegistry().unregisterConsumer(name);
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
async compact({ throughSequence, journalId = 'global' } = {}) {
|
|
194
|
+
return compactJournal(selectJournal(journalId), getRegistry(), { throughSequence, journalId });
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
async getHealth() {
|
|
198
|
+
if (!homeExists()) {
|
|
199
|
+
return {
|
|
200
|
+
journalSizeBytes: 0,
|
|
201
|
+
firstSequence: 0,
|
|
202
|
+
lastSequence: 0,
|
|
203
|
+
minConsumerAck: null,
|
|
204
|
+
oldestUnackedAt: null,
|
|
205
|
+
consumers: []
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
const liveJournal = getJournal();
|
|
209
|
+
const [bounds, consumers, minConsumerAck] = await Promise.all([
|
|
210
|
+
liveJournal.getBounds(),
|
|
211
|
+
getRegistry().getConsumers(),
|
|
212
|
+
getRegistry().getMinAck()
|
|
213
|
+
]);
|
|
214
|
+
let journalSizeBytes = 0;
|
|
215
|
+
try {
|
|
216
|
+
journalSizeBytes = fs.statSync(liveJournal.eventsFile).size;
|
|
217
|
+
} catch (_) {
|
|
218
|
+
journalSizeBytes = 0;
|
|
219
|
+
}
|
|
220
|
+
let oldestUnackedAt = null;
|
|
221
|
+
if (minConsumerAck !== null && bounds.lastSequence > minConsumerAck) {
|
|
222
|
+
try {
|
|
223
|
+
const pending = await liveJournal.readEvents({ fromSequence: minConsumerAck + 1, toSequence: minConsumerAck + 1, limit: 1 });
|
|
224
|
+
if (pending.length) oldestUnackedAt = pending[0].capturedAt || null;
|
|
225
|
+
} catch (_) {
|
|
226
|
+
oldestUnackedAt = null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
journalSizeBytes,
|
|
231
|
+
firstSequence: bounds.firstSequence,
|
|
232
|
+
lastSequence: bounds.lastSequence,
|
|
233
|
+
minConsumerAck,
|
|
234
|
+
oldestUnackedAt,
|
|
235
|
+
consumers: consumers.map((consumer) => ({
|
|
236
|
+
name: consumer.name,
|
|
237
|
+
ack: consumer.ack,
|
|
238
|
+
lastSeenAt: consumer.lastSeenAt || null
|
|
239
|
+
}))
|
|
240
|
+
};
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
// Introspection helpers for diagnostics; not part of the frozen contract.
|
|
244
|
+
get _journal() { return journal; },
|
|
245
|
+
get _registry() { return registry; }
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
module.exports = { createBridge };
|