@sanqianx/ai-coding-event-bridge-console 0.5.1 → 0.6.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/package.json +2 -5
- package/src/agent-detect.js +1 -1
- package/src/bin.js +23 -4
- 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 +205 -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 +101 -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 +94 -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 +2 -2
|
@@ -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,205 @@
|
|
|
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
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Stable host-facing facade over the Bridge runtime. Hosts (Project-Knowledge,
|
|
15
|
+
* DevTask-Radar, ...) must talk to this facade only — never to Journal
|
|
16
|
+
* internals or Bridge filesystem layout.
|
|
17
|
+
*/
|
|
18
|
+
function createBridge({ homeDir } = {}) {
|
|
19
|
+
const home = bridgeHome(homeDir);
|
|
20
|
+
// Lazy internals: constructing a facade must not touch the filesystem, so
|
|
21
|
+
// merely loading a Bridge (e.g. in a host test) creates no runtime home.
|
|
22
|
+
let journal = null;
|
|
23
|
+
let registry = null;
|
|
24
|
+
const getJournal = () => {
|
|
25
|
+
if (!journal) journal = new Journal(path.join(home, 'journal'));
|
|
26
|
+
return journal;
|
|
27
|
+
};
|
|
28
|
+
const getRegistry = () => {
|
|
29
|
+
if (!registry) registry = new ConsumerRegistry(home);
|
|
30
|
+
return registry;
|
|
31
|
+
};
|
|
32
|
+
// Pure reads on a machine where no Bridge home exists yet must not
|
|
33
|
+
// materialize one: status queries stay side-effect free.
|
|
34
|
+
const homeExists = () => fs.existsSync(home);
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
homeDir: home,
|
|
38
|
+
|
|
39
|
+
async getHighWatermark() {
|
|
40
|
+
if (!homeExists()) return 0;
|
|
41
|
+
const bounds = await getJournal().getBounds();
|
|
42
|
+
return bounds.lastSequence;
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async readEvents(options = {}) {
|
|
46
|
+
return getJournal().readEvents(options);
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
// Host-side injection for tests and explicit workflows; automatic capture
|
|
50
|
+
// flows through the connectors' canonical append path.
|
|
51
|
+
async appendConversationEvent(event) {
|
|
52
|
+
return getJournal().appendConversationEvent(event);
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async appendCommitBoundary({ projectId, repoIdentity, commitSha, parentShas, parents, branch, subject, committedAt, operationId, meta } = {}) {
|
|
56
|
+
if (!repoIdentity) {
|
|
57
|
+
throw new Error('appendCommitBoundary requires repoIdentity');
|
|
58
|
+
}
|
|
59
|
+
// Boundaries live in the same journal as the repo's conversation events
|
|
60
|
+
// (openTurnIdsAtCommit is computed from that journal's open-turn state).
|
|
61
|
+
const journal = journalFor(home, repoIdentity);
|
|
62
|
+
const result = await journal.appendCommitBoundary(repoIdentity, {
|
|
63
|
+
commitSha,
|
|
64
|
+
parents: parents !== undefined ? parents : parentShas,
|
|
65
|
+
branch,
|
|
66
|
+
subject,
|
|
67
|
+
committedAt,
|
|
68
|
+
projectId,
|
|
69
|
+
operationId,
|
|
70
|
+
meta
|
|
71
|
+
});
|
|
72
|
+
const routed = journal.journalDir !== globalJournalDir(home);
|
|
73
|
+
let bridgeCursorAtCommit = result.sequence;
|
|
74
|
+
if (routed) {
|
|
75
|
+
// Routed to a registered project's journal: the boundary is not in the
|
|
76
|
+
// global journal, so the global consumer watermark is unchanged. Keep
|
|
77
|
+
// the returned cursor conservative (never past unseen global content).
|
|
78
|
+
const bounds = await getJournal().getBounds();
|
|
79
|
+
bridgeCursorAtCommit = bounds.lastSequence;
|
|
80
|
+
}
|
|
81
|
+
// A routed boundary means a registered project just committed: freeze
|
|
82
|
+
// that commit's conversations into <store>/commits/<sha>.md. Projection
|
|
83
|
+
// failures are reported, never thrown — the journal remains the only
|
|
84
|
+
// source of truth and the boundary is already durable.
|
|
85
|
+
let projection = { sealed: false, turnCount: 0, reason: routed ? 'project-not-registered' : 'global-journal' };
|
|
86
|
+
if (routed) {
|
|
87
|
+
try {
|
|
88
|
+
const project = projectRegistry.findProject(home, repoIdentity);
|
|
89
|
+
if (project) {
|
|
90
|
+
const sealDir = sealDirFor(project.store);
|
|
91
|
+
projection = await sealCommitConversations({
|
|
92
|
+
journal,
|
|
93
|
+
sealDir,
|
|
94
|
+
boundary: {
|
|
95
|
+
commitSha,
|
|
96
|
+
branch,
|
|
97
|
+
subject,
|
|
98
|
+
committedAt,
|
|
99
|
+
sequence: result.sequence,
|
|
100
|
+
openTurnIdsAtCommit: result.openTurnIdsAtCommit,
|
|
101
|
+
previousRepoBoundarySequence: result.previousRepoBoundarySequence
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
// The journal is a conveyor: once the archive copy verifiably
|
|
105
|
+
// exists on disk, drop the sealed prefix. On any miss the prefix
|
|
106
|
+
// stays and the next boundary's seal window covers it (self-heal).
|
|
107
|
+
if (projection.sealed || (projection.reason === 'already-sealed' && fs.existsSync(projection.path))) {
|
|
108
|
+
const trim = await journal.trimThrough(result.sequence);
|
|
109
|
+
projection.trimmedThrough = trim.trimmedThrough;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} catch (err) {
|
|
113
|
+
projection = { sealed: false, turnCount: 0, error: err && err.message ? err.message : String(err) };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
sequence: result.sequence,
|
|
118
|
+
bridgeCursorAtCommit,
|
|
119
|
+
openTurnIdsAtCommit: result.openTurnIdsAtCommit,
|
|
120
|
+
previousRepoBoundarySequence: result.previousRepoBoundarySequence,
|
|
121
|
+
committedAt: committedAt || new Date().toISOString(),
|
|
122
|
+
projection
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
async registerConsumer(name, meta = {}) {
|
|
127
|
+
return getRegistry().registerConsumer(name, meta);
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
async getConsumer(name) {
|
|
131
|
+
if (!homeExists()) return null;
|
|
132
|
+
const consumers = await getRegistry().getConsumers();
|
|
133
|
+
return consumers.find((consumer) => consumer.name === name) || null;
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
async listConsumers() {
|
|
137
|
+
if (!homeExists()) return [];
|
|
138
|
+
return getRegistry().getConsumers();
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
async ackConsumerCursor(name, sequence) {
|
|
142
|
+
return getRegistry().ackConsumerCursor(name, sequence);
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
async unregisterConsumer(name) {
|
|
146
|
+
return getRegistry().unregisterConsumer(name);
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
async compact({ throughSequence } = {}) {
|
|
150
|
+
return compactJournal(getJournal(), getRegistry(), { throughSequence });
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
async getHealth() {
|
|
154
|
+
if (!homeExists()) {
|
|
155
|
+
return {
|
|
156
|
+
journalSizeBytes: 0,
|
|
157
|
+
firstSequence: 0,
|
|
158
|
+
lastSequence: 0,
|
|
159
|
+
minConsumerAck: null,
|
|
160
|
+
oldestUnackedAt: null,
|
|
161
|
+
consumers: []
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const liveJournal = getJournal();
|
|
165
|
+
const [bounds, consumers, minConsumerAck] = await Promise.all([
|
|
166
|
+
liveJournal.getBounds(),
|
|
167
|
+
getRegistry().getConsumers(),
|
|
168
|
+
getRegistry().getMinAck()
|
|
169
|
+
]);
|
|
170
|
+
let journalSizeBytes = 0;
|
|
171
|
+
try {
|
|
172
|
+
journalSizeBytes = fs.statSync(liveJournal.eventsFile).size;
|
|
173
|
+
} catch (_) {
|
|
174
|
+
journalSizeBytes = 0;
|
|
175
|
+
}
|
|
176
|
+
let oldestUnackedAt = null;
|
|
177
|
+
if (minConsumerAck !== null && bounds.lastSequence > minConsumerAck) {
|
|
178
|
+
try {
|
|
179
|
+
const pending = await liveJournal.readEvents({ fromSequence: minConsumerAck + 1, toSequence: minConsumerAck + 1, limit: 1 });
|
|
180
|
+
if (pending.length) oldestUnackedAt = pending[0].capturedAt || null;
|
|
181
|
+
} catch (_) {
|
|
182
|
+
oldestUnackedAt = null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
journalSizeBytes,
|
|
187
|
+
firstSequence: bounds.firstSequence,
|
|
188
|
+
lastSequence: bounds.lastSequence,
|
|
189
|
+
minConsumerAck,
|
|
190
|
+
oldestUnackedAt,
|
|
191
|
+
consumers: consumers.map((consumer) => ({
|
|
192
|
+
name: consumer.name,
|
|
193
|
+
ack: consumer.ack,
|
|
194
|
+
lastSeenAt: consumer.lastSeenAt || null
|
|
195
|
+
}))
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
// Introspection helpers for diagnostics; not part of the frozen contract.
|
|
200
|
+
get _journal() { return journal; },
|
|
201
|
+
get _registry() { return registry; }
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = { createBridge };
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { ensureDirSync, writeTextAtomicSync } = require('./fs-utils');
|
|
7
|
+
const { EVENT_SCHEMA, BOUNDARY_SCHEMA } = require('./journal');
|
|
8
|
+
const { ConversationQuery } = require('../query/conversation-query');
|
|
9
|
+
|
|
10
|
+
// Per-commit conversation sealing: a read-only projection of the routed
|
|
11
|
+
// project journal. When a commit boundary lands in a registered project's
|
|
12
|
+
// journal, the conversations belonging to that commit are frozen into
|
|
13
|
+
// `<store>/commits/<commitSha>.md`. The journal stays the only source of
|
|
14
|
+
// truth — sealed files are rebuildable at any time via rebuildSealedFiles.
|
|
15
|
+
|
|
16
|
+
const COMMIT_SHA_PATTERN = /^[0-9a-f]{7,64}$/;
|
|
17
|
+
|
|
18
|
+
function isUncommittedBoundary(boundary) {
|
|
19
|
+
return Boolean(boundary && boundary.meta && boundary.meta.uncommitted);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sealDirFor(storeDir) {
|
|
23
|
+
return path.join(storeDir, 'commits');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sha256Hex(value) {
|
|
27
|
+
return `sha256:${crypto.createHash('sha256').update(value, 'utf8').digest('hex')}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isConversationEvent(record) {
|
|
31
|
+
return record.schema === EVENT_SCHEMA && (record.eventType === 'user_prompt' || record.eventType === 'assistant_response');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isBoundaryRecord(record) {
|
|
35
|
+
return record.schema === BOUNDARY_SCHEMA;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A fence longer than any backtick run inside the content, so bodies can
|
|
39
|
+
// never break out of their block.
|
|
40
|
+
function fenceFor(content) {
|
|
41
|
+
const runs = String(content == null ? '' : content).match(/`+/g) || [];
|
|
42
|
+
const longest = runs.reduce((max, run) => Math.max(max, run.length), 0);
|
|
43
|
+
return '`'.repeat(Math.max(3, longest + 1));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function yamlScalar(value) {
|
|
47
|
+
if (value === null || value === undefined) return 'null';
|
|
48
|
+
if (typeof value === 'number') return String(value);
|
|
49
|
+
const text = String(value);
|
|
50
|
+
if (/^[A-Za-z0-9][A-Za-z0-9_.\/-]*$/.test(text)) return text;
|
|
51
|
+
return `'${text.replace(/'/g, "''")}'`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function localDay(iso) {
|
|
55
|
+
if (!iso) return null;
|
|
56
|
+
const d = new Date(iso);
|
|
57
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
58
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
59
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function localDayTime(iso) {
|
|
63
|
+
if (!iso) return '';
|
|
64
|
+
const d = new Date(iso);
|
|
65
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
66
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
67
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function renderEventBlock(label, event) {
|
|
71
|
+
const fence = fenceFor(event.content);
|
|
72
|
+
return [`### ${label}`, '', fence, String(event.content == null ? '' : event.content), fence, ''].join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function renderSealedMarkdown({ boundary, turns, unpaired }) {
|
|
76
|
+
// sealedAt is derived from persisted boundary facts (never the wall clock)
|
|
77
|
+
// so a rebuild reproduces byte-identical files.
|
|
78
|
+
const sealedAt = boundary.capturedAt || boundary.committedAt || null;
|
|
79
|
+
const uncommitted = isUncommittedBoundary(boundary);
|
|
80
|
+
const lines = ['---'];
|
|
81
|
+
if (uncommitted) {
|
|
82
|
+
lines.push('subject: 未提交的对话');
|
|
83
|
+
lines.push('uncommitted: true');
|
|
84
|
+
} else {
|
|
85
|
+
lines.push(`commitSha: ${yamlScalar(boundary.commitSha)}`);
|
|
86
|
+
if (boundary.subject !== undefined && boundary.subject !== null) {
|
|
87
|
+
lines.push(`subject: ${yamlScalar(boundary.subject)}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (boundary.branch !== undefined && boundary.branch !== null) lines.push(`branch: ${yamlScalar(boundary.branch)}`);
|
|
91
|
+
if (boundary.committedAt) lines.push(`committedAt: ${yamlScalar(boundary.committedAt)}`);
|
|
92
|
+
if (sealedAt) lines.push(`sealedAt: ${yamlScalar(sealedAt)}`);
|
|
93
|
+
lines.push(`boundarySequence: ${yamlScalar(boundary.sequence)}`);
|
|
94
|
+
lines.push(`previousBoundarySequence: ${yamlScalar(boundary.previousRepoBoundarySequence == null ? null : boundary.previousRepoBoundarySequence)}`);
|
|
95
|
+
lines.push(`turnCount: ${yamlScalar(turns.length)}`);
|
|
96
|
+
lines.push(`unpairedEventCount: ${yamlScalar(unpaired.length)}`);
|
|
97
|
+
lines.push(`contentHash: ${yamlScalar(sha256Hex(canonicalProof({ boundary, turns, unpaired })))}`);
|
|
98
|
+
lines.push('---', '');
|
|
99
|
+
turns.forEach((turn, index) => {
|
|
100
|
+
const first = (turn.userEvents[0] || turn.assistantEvents[0]);
|
|
101
|
+
const when = localDayTime(first && first.capturedAt);
|
|
102
|
+
const confidence = (first && first.identityConfidence) || 'unavailable';
|
|
103
|
+
lines.push(`## Turn ${index + 1}${when ? ` — ${when}` : ''} (${turn.turnId}, ${confidence})`, '');
|
|
104
|
+
turn.userEvents.forEach((event) => lines.push(renderEventBlock('用户', event)));
|
|
105
|
+
turn.assistantEvents.forEach((event) => lines.push(renderEventBlock('助手', event)));
|
|
106
|
+
});
|
|
107
|
+
if (unpaired.length) {
|
|
108
|
+
lines.push('## 未配对事件', '');
|
|
109
|
+
unpaired.forEach((event) => {
|
|
110
|
+
const label = `${event.role === 'user' ? '用户' : '助手'}${event.turnId ? ` — ${event.turnId}` : ''}`;
|
|
111
|
+
lines.push(renderEventBlock(label, event));
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return `${lines.join('\n')}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Provenance digest over the sealed bodies (aligned with the knowledge base's
|
|
118
|
+
// frozen-snapshot hashing: content-addressed, no volatile fields).
|
|
119
|
+
function canonicalProof({ boundary, turns, unpaired }) {
|
|
120
|
+
return JSON.stringify({
|
|
121
|
+
commitSha: String(boundary.commitSha),
|
|
122
|
+
turns: turns.map((turn) => ({
|
|
123
|
+
turnId: turn.turnId,
|
|
124
|
+
events: turn.userEvents.concat(turn.assistantEvents).map((event) => [event.eventId, event.role, event.content])
|
|
125
|
+
})),
|
|
126
|
+
unpaired: unpaired.map((event) => [event.eventId, event.role, event.content])
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Freeze the conversations belonging to one commit boundary into
|
|
132
|
+
* `<sealDir>/<commitSha>.md`.
|
|
133
|
+
*
|
|
134
|
+
* Belongs-to rule: conversation events with sequence in
|
|
135
|
+
* (previousRepoBoundarySequence, boundary.sequence], plus every turn that was
|
|
136
|
+
* still open at commit time (openTurnIdsAtCommit — the user's ask is the
|
|
137
|
+
* requirement truth even when the reply has not arrived yet). Window events
|
|
138
|
+
* that no selected turn consumed (e.g. a reply that arrived after the previous
|
|
139
|
+
* commit sealed its turn's prompt) are appended as 未配对事件, so every
|
|
140
|
+
* conversation body in the journal lands in at least one sealed file.
|
|
141
|
+
*
|
|
142
|
+
* @returns {sealed, path?, turnCount, unpairedEventCount?, reason?}
|
|
143
|
+
*/
|
|
144
|
+
async function sealCommitConversations({ journal, sealDir, boundary } = {}) {
|
|
145
|
+
if (!journal) throw new Error('sealCommitConversations requires journal');
|
|
146
|
+
if (typeof sealDir !== 'string' || !sealDir) throw new Error('sealCommitConversations requires sealDir');
|
|
147
|
+
if (!boundary || typeof boundary !== 'object') throw new Error('sealCommitConversations requires boundary');
|
|
148
|
+
const uncommitted = isUncommittedBoundary(boundary);
|
|
149
|
+
const commitSha = String(boundary.commitSha || '').trim().toLowerCase();
|
|
150
|
+
let target;
|
|
151
|
+
if (uncommitted) {
|
|
152
|
+
const day = localDay(boundary.capturedAt || boundary.committedAt) || 'unknown-date';
|
|
153
|
+
if (typeof boundary.sequence !== 'number') {
|
|
154
|
+
return { sealed: false, turnCount: 0, reason: 'boundary-sequence-missing' };
|
|
155
|
+
}
|
|
156
|
+
target = path.join(sealDir, `${day}-uncommitted-${boundary.sequence}.md`);
|
|
157
|
+
} else {
|
|
158
|
+
if (!COMMIT_SHA_PATTERN.test(commitSha)) {
|
|
159
|
+
return { sealed: false, turnCount: 0, reason: 'no-commit-sha' };
|
|
160
|
+
}
|
|
161
|
+
target = path.join(sealDir, `${commitSha}.md`);
|
|
162
|
+
}
|
|
163
|
+
if (fs.existsSync(target)) {
|
|
164
|
+
return { sealed: false, path: target, turnCount: 0, reason: 'already-sealed' };
|
|
165
|
+
}
|
|
166
|
+
if (typeof boundary.sequence !== 'number') {
|
|
167
|
+
return { sealed: false, turnCount: 0, reason: 'boundary-sequence-missing' };
|
|
168
|
+
}
|
|
169
|
+
const to = boundary.sequence;
|
|
170
|
+
const from = Math.max((typeof boundary.previousRepoBoundarySequence === 'number' ? boundary.previousRepoBoundarySequence : 0) + 1, 1);
|
|
171
|
+
|
|
172
|
+
// Open turns may start before the window, so turns are assembled over the
|
|
173
|
+
// whole prefix up to the boundary — not just the window itself.
|
|
174
|
+
const prefix = (await journal.readEvents({ fromSequence: 1, toSequence: to })).filter(isConversationEvent);
|
|
175
|
+
if (!prefix.length) {
|
|
176
|
+
return { sealed: false, turnCount: 0, reason: 'no-conversations' };
|
|
177
|
+
}
|
|
178
|
+
const query = new ConversationQuery({ journal });
|
|
179
|
+
// _projectTurns is package-internal reuse: the projection shares the exact
|
|
180
|
+
// turn-assembly rule the console's browsing view uses.
|
|
181
|
+
const allTurns = query._projectTurns(prefix);
|
|
182
|
+
const openIds = new Set(boundary.openTurnIdsAtCommit || []);
|
|
183
|
+
const turns = allTurns
|
|
184
|
+
.filter((turn) => openIds.has(turn.turnId) || (turn.startSequence >= from && turn.startSequence <= to))
|
|
185
|
+
.sort((a, b) => a.startSequence - b.startSequence);
|
|
186
|
+
|
|
187
|
+
const consumed = new Set();
|
|
188
|
+
for (const turn of turns) {
|
|
189
|
+
for (const event of turn.userEvents.concat(turn.assistantEvents)) consumed.add(event.eventId);
|
|
190
|
+
}
|
|
191
|
+
const unpaired = prefix.filter((event) => event.sequence >= from && event.sequence <= to && !consumed.has(event.eventId));
|
|
192
|
+
if (!turns.length && !unpaired.length) {
|
|
193
|
+
return { sealed: false, turnCount: 0, reason: 'no-conversations' };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const markdown = renderSealedMarkdown({ boundary: { ...boundary, commitSha }, turns, unpaired });
|
|
197
|
+
ensureDirSync(sealDir);
|
|
198
|
+
writeTextAtomicSync(target, `${markdown}\n`);
|
|
199
|
+
return { sealed: true, path: target, turnCount: turns.length, unpairedEventCount: unpaired.length };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Age fuse for conversations that will never be committed: when the newest
|
|
204
|
+
* conversation event after the last boundary is older than maxAgeDays, append
|
|
205
|
+
* a synthetic boundary (meta.uncommitted) and seal the tail into
|
|
206
|
+
* commits/<day>-uncommitted-<seq>.md, then trim the journal. Idempotent per
|
|
207
|
+
* run: nothing happens while no new events arrived after the last boundary.
|
|
208
|
+
*/
|
|
209
|
+
async function sealUncommittedTail({ journal, sealDir, maxAgeDays = 14, now = Date.now() } = {}) {
|
|
210
|
+
if (!journal) throw new Error('sealUncommittedTail requires journal');
|
|
211
|
+
if (typeof sealDir !== 'string' || !sealDir) throw new Error('sealUncommittedTail requires sealDir');
|
|
212
|
+
const records = await journal.readEvents({});
|
|
213
|
+
const boundaries = records.filter(isBoundaryRecord);
|
|
214
|
+
const lastBoundarySeq = boundaries.reduce((max, b) => Math.max(max, b.sequence), 0);
|
|
215
|
+
const tailEvents = records.filter(isConversationEvent).filter((e) => e.sequence > lastBoundarySeq);
|
|
216
|
+
if (!tailEvents.length) {
|
|
217
|
+
return { sealed: false, reason: 'nothing-after-last-boundary' };
|
|
218
|
+
}
|
|
219
|
+
const newest = tailEvents.reduce((a, b) => (String(b.capturedAt || '') > String(a.capturedAt || '') ? b : a));
|
|
220
|
+
const ageMs = now - Date.parse(newest.capturedAt || '');
|
|
221
|
+
if (!Number.isFinite(ageMs) || ageMs < maxAgeDays * 86400000) {
|
|
222
|
+
return { sealed: false, reason: 'too-young' };
|
|
223
|
+
}
|
|
224
|
+
const repoIdentity = newest.repoIdentity;
|
|
225
|
+
if (!repoIdentity) {
|
|
226
|
+
return { sealed: false, reason: 'no-repo-identity' };
|
|
227
|
+
}
|
|
228
|
+
const appended = await journal.appendCommitBoundary(repoIdentity, { meta: { uncommitted: true } });
|
|
229
|
+
const boundary = {
|
|
230
|
+
commitSha: undefined,
|
|
231
|
+
capturedAt: newest.capturedAt,
|
|
232
|
+
sequence: appended.sequence,
|
|
233
|
+
openTurnIdsAtCommit: appended.openTurnIdsAtCommit,
|
|
234
|
+
previousRepoBoundarySequence: appended.previousRepoBoundarySequence,
|
|
235
|
+
meta: { uncommitted: true }
|
|
236
|
+
};
|
|
237
|
+
const result = await sealCommitConversations({ journal, sealDir, boundary });
|
|
238
|
+
if (result.sealed || (result.reason === 'already-sealed' && fs.existsSync(result.path))) {
|
|
239
|
+
const trim = await journal.trimThrough(appended.sequence);
|
|
240
|
+
return { ...result, trimmedThrough: trim.trimmedThrough };
|
|
241
|
+
}
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Boot-time reconciliation: trim the journal through the newest boundary
|
|
247
|
+
* whose sealed file verifiably exists on disk. Heals the window between a
|
|
248
|
+
* successful seal and a crashed trim, and adopts pre-upgrade journals that
|
|
249
|
+
* already carry sealed commits.
|
|
250
|
+
*/
|
|
251
|
+
async function reconcileTrim({ journal, sealDir } = {}) {
|
|
252
|
+
if (!journal) throw new Error('reconcileTrim requires journal');
|
|
253
|
+
if (typeof sealDir !== 'string' || !sealDir) throw new Error('reconcileTrim requires sealDir');
|
|
254
|
+
const records = await journal.readEvents({});
|
|
255
|
+
const boundaries = records
|
|
256
|
+
.filter(isBoundaryRecord)
|
|
257
|
+
.filter((b) => COMMIT_SHA_PATTERN.test(String(b.commitSha || '')))
|
|
258
|
+
.sort((a, b) => b.sequence - a.sequence);
|
|
259
|
+
for (const boundary of boundaries) {
|
|
260
|
+
if (fs.existsSync(path.join(sealDir, `${String(boundary.commitSha).toLowerCase()}.md`))) {
|
|
261
|
+
const trim = await journal.trimThrough(boundary.sequence);
|
|
262
|
+
return { trimmedThrough: trim.trimmedThrough, commitSha: boundary.commitSha, reason: trim.reason };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return { trimmedThrough: null, reason: 'no-sealed-boundary' };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Re-create any missing sealed file from the journal's persisted boundary
|
|
270
|
+
* records (disaster recovery). Existing files are left untouched, so a rebuild
|
|
271
|
+
* never rewrites history.
|
|
272
|
+
*/
|
|
273
|
+
async function rebuildSealedFiles({ journal, sealDir } = {}) {
|
|
274
|
+
if (!journal) throw new Error('rebuildSealedFiles requires journal');
|
|
275
|
+
if (typeof sealDir !== 'string' || !sealDir) throw new Error('rebuildSealedFiles requires sealDir');
|
|
276
|
+
const boundaries = (await journal.readEvents({})).filter(isBoundaryRecord);
|
|
277
|
+
const results = [];
|
|
278
|
+
for (const boundary of boundaries) {
|
|
279
|
+
results.push(await sealCommitConversations({ journal, sealDir, boundary }));
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
boundaryCount: boundaries.length,
|
|
283
|
+
sealedCount: results.filter((result) => result.sealed).length,
|
|
284
|
+
results
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = { sealDirFor, sealCommitConversations, rebuildSealedFiles, sealUncommittedTail, reconcileTrim };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compact the durable journal prefix that every registered consumer has acked.
|
|
5
|
+
* Never removes data beyond minAck; requires at least one registered consumer;
|
|
6
|
+
* runs under the same journal lock as appendEvent/appendCommitBoundary.
|
|
7
|
+
*/
|
|
8
|
+
async function compactJournal(journal, consumerRegistry, { throughSequence } = {}) {
|
|
9
|
+
const minAck = await consumerRegistry.getMinAck();
|
|
10
|
+
if (minAck === null || minAck === undefined) {
|
|
11
|
+
return { compactedThrough: null, removedCount: 0, reason: 'no-consumers' };
|
|
12
|
+
}
|
|
13
|
+
const target = Math.min(minAck, throughSequence === undefined ? minAck : throughSequence);
|
|
14
|
+
return journal._withJournalLock(async () => {
|
|
15
|
+
if (target < journal.firstSequence) {
|
|
16
|
+
return { compactedThrough: journal.firstSequence - 1, removedCount: 0, reason: 'already-compacted' };
|
|
17
|
+
}
|
|
18
|
+
const removedCount = journal._compactTo(target);
|
|
19
|
+
return { compactedThrough: target, removedCount, reason: 'compacted' };
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { compactJournal };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { CrossProcessLock, lockPathFor } = require('./lock');
|
|
6
|
+
const { writeJsonAtomicSync, readJsonIfExists, ensureDirSync } = require('./fs-utils');
|
|
7
|
+
|
|
8
|
+
class ConsumerRegistry {
|
|
9
|
+
constructor(homeDir) {
|
|
10
|
+
// Pure reads on a machine without a Bridge home must not create one;
|
|
11
|
+
// the directory is materialized on the first write.
|
|
12
|
+
this.homeDir = homeDir;
|
|
13
|
+
this.consumersFile = path.join(homeDir, 'consumers.json');
|
|
14
|
+
this.lock = new CrossProcessLock(lockPathFor(homeDir, 'install.lock'));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async _withLock(fn) {
|
|
18
|
+
return this.lock.withLock(fn);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
_read() {
|
|
22
|
+
const saved = readJsonIfExists(this.consumersFile);
|
|
23
|
+
if (saved && typeof saved === 'object' && Array.isArray(saved.consumers)) {
|
|
24
|
+
return saved;
|
|
25
|
+
}
|
|
26
|
+
return { schema: 'bridge-consumers/v1', consumers: [] };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_write(state) {
|
|
30
|
+
ensureDirSync(this.homeDir);
|
|
31
|
+
writeJsonAtomicSync(this.consumersFile, state);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async registerConsumer(name, meta = {}) {
|
|
35
|
+
if (typeof name !== 'string' || !name) {
|
|
36
|
+
throw new TypeError('consumer name is required');
|
|
37
|
+
}
|
|
38
|
+
return this._withLock(async () => {
|
|
39
|
+
const state = this._read();
|
|
40
|
+
let entry = state.consumers.find((c) => c.name === name);
|
|
41
|
+
if (!entry) {
|
|
42
|
+
entry = { name, ack: 0, registeredAt: new Date().toISOString(), meta: {} };
|
|
43
|
+
state.consumers.push(entry);
|
|
44
|
+
}
|
|
45
|
+
entry.meta = { ...(entry.meta || {}), ...meta };
|
|
46
|
+
entry.lastSeenAt = new Date().toISOString();
|
|
47
|
+
this._write(state);
|
|
48
|
+
return { ...entry };
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async unregisterConsumer(name) {
|
|
53
|
+
return this._withLock(async () => {
|
|
54
|
+
const state = this._read();
|
|
55
|
+
const before = state.consumers.length;
|
|
56
|
+
state.consumers = state.consumers.filter((c) => c.name !== name);
|
|
57
|
+
this._write(state);
|
|
58
|
+
return { removed: before - state.consumers.length };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async ackConsumerCursor(name, sequence) {
|
|
63
|
+
if (!Number.isInteger(sequence) || sequence < 0) {
|
|
64
|
+
throw new TypeError('ack sequence must be a non-negative integer');
|
|
65
|
+
}
|
|
66
|
+
return this._withLock(async () => {
|
|
67
|
+
const state = this._read();
|
|
68
|
+
const entry = state.consumers.find((c) => c.name === name);
|
|
69
|
+
if (!entry) {
|
|
70
|
+
const err = new Error(`consumer ${name} is not registered`);
|
|
71
|
+
err.code = 'CONSUMER_NOT_REGISTERED';
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
if (sequence > entry.ack) {
|
|
75
|
+
entry.ack = sequence;
|
|
76
|
+
}
|
|
77
|
+
entry.lastSeenAt = new Date().toISOString();
|
|
78
|
+
this._write(state);
|
|
79
|
+
return { name, ack: entry.ack };
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async getConsumers() {
|
|
84
|
+
if (!fs.existsSync(this.consumersFile)) return [];
|
|
85
|
+
return this._withLock(async () => {
|
|
86
|
+
const state = this._read();
|
|
87
|
+
return state.consumers.map((c) => ({ ...c }));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async getMinAck() {
|
|
92
|
+
if (!fs.existsSync(this.consumersFile)) return null;
|
|
93
|
+
return this._withLock(async () => {
|
|
94
|
+
const state = this._read();
|
|
95
|
+
if (state.consumers.length === 0) return null;
|
|
96
|
+
return state.consumers.reduce((min, c) => Math.min(min, c.ack), Number.POSITIVE_INFINITY);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { ConsumerRegistry };
|