@sanqianx/ai-coding-event-bridge-console 0.7.2 → 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.
Files changed (37) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +81 -81
  3. package/console/index.html +953 -953
  4. package/package.json +26 -26
  5. package/src/agent-detect.js +128 -128
  6. package/src/bin.js +80 -80
  7. package/src/bridge/bin/commit-boundary.js +64 -64
  8. package/src/bridge/connectors/capture-guard.js +24 -24
  9. package/src/bridge/connectors/claude-code/hook-entry.js +45 -45
  10. package/src/bridge/connectors/claude-code/notify.js +57 -57
  11. package/src/bridge/connectors/codex/cursor-store.js +83 -83
  12. package/src/bridge/connectors/codex/hook-entry.js +215 -215
  13. package/src/bridge/connectors/codex/session-parser.js +100 -100
  14. package/src/bridge/connectors/opencode/hook-entry.js +55 -55
  15. package/src/bridge/core/bridge.js +249 -246
  16. package/src/bridge/core/commit-projection.js +288 -288
  17. package/src/bridge/core/compaction.js +23 -23
  18. package/src/bridge/core/consumer-registry.js +111 -111
  19. package/src/bridge/core/event-schema.js +127 -127
  20. package/src/bridge/core/fs-utils.js +81 -81
  21. package/src/bridge/core/journal-router.js +39 -39
  22. package/src/bridge/core/journal.js +507 -507
  23. package/src/bridge/core/lock.js +96 -96
  24. package/src/bridge/core/normalizer.js +114 -114
  25. package/src/bridge/core/paths.js +12 -12
  26. package/src/bridge/core/project-registry.js +176 -176
  27. package/src/bridge/core/repo-context.js +191 -191
  28. package/src/bridge/core/runtime-home.js +189 -189
  29. package/src/bridge/core/semver.js +23 -23
  30. package/src/bridge/core/turn-identity.js +127 -127
  31. package/src/bridge/index.js +86 -86
  32. package/src/bridge/installers/claude-code/installer.js +181 -181
  33. package/src/bridge/installers/codex/installer.js +114 -114
  34. package/src/bridge/installers/opencode/installer.js +96 -96
  35. package/src/bridge/installers/zcode/installer.js +200 -200
  36. package/src/bridge/query/conversation-query.js +194 -194
  37. 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
- 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 };
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 };