@sanqianx/ai-coding-event-bridge-console 0.4.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/LICENSE +21 -0
- package/README.md +63 -0
- package/console/index.html +921 -0
- package/package.json +29 -0
- package/src/agent-detect.js +128 -0
- package/src/bin.js +53 -0
- package/src/pick-folder.js +623 -0
- package/src/server.js +719 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const {
|
|
6
|
+
Journal,
|
|
7
|
+
ConversationQuery,
|
|
8
|
+
ConsumerRegistry,
|
|
9
|
+
projectRegistry,
|
|
10
|
+
commitProjection,
|
|
11
|
+
ensureRuntimeHome,
|
|
12
|
+
semver,
|
|
13
|
+
globalJournalDir,
|
|
14
|
+
projectJournalDir,
|
|
15
|
+
repoIdentityKey
|
|
16
|
+
} = require('@sanqianx/ai-coding-event-bridge');
|
|
17
|
+
const { pickFolder } = require('./pick-folder');
|
|
18
|
+
const { detectAgents } = require('./agent-detect');
|
|
19
|
+
const corePackage = require('@sanqianx/ai-coding-event-bridge/package.json');
|
|
20
|
+
|
|
21
|
+
const BOUNDARY_SCHEMA = 'git-commit-boundary/v1';
|
|
22
|
+
const EVENT_SCHEMA = 'ai-coding-event/v1';
|
|
23
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
24
|
+
const COMMIT_DOC_PATTERN = /^(?:[0-9a-f]{7,64}|[0-9]{4}-[0-9]{2}-[0-9]{2}-uncommitted-\d+)\.md$/i;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Local console server for the AI coding event bridge. Serves the explorer
|
|
28
|
+
* page plus a JSON API over the bridge journals:
|
|
29
|
+
*
|
|
30
|
+
* - registered projects (imported via POST /api/projects) own a journal at
|
|
31
|
+
* <store>/journal; the journal is a conveyor that only keeps the current
|
|
32
|
+
* (uncommitted) conversation, and commits/<sha>.md files are the archive;
|
|
33
|
+
* - auto-discovered projects (captured but never imported) read from the
|
|
34
|
+
* global journal only;
|
|
35
|
+
* - besides importing projects, the console maintains each project journal:
|
|
36
|
+
* at startup (and before current-conversation reads) it reconciles the
|
|
37
|
+
* trim watermark against sealed files on disk and fires the age fuse for
|
|
38
|
+
* never-committed tails. Maintenance is best-effort and never fails a
|
|
39
|
+
* request.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
function localDay(iso) {
|
|
43
|
+
if (typeof iso !== 'string' || !iso) return null;
|
|
44
|
+
const d = new Date(iso);
|
|
45
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
46
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function encodeCursor(payload) {
|
|
50
|
+
return Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function decodeCursor(raw) {
|
|
54
|
+
try {
|
|
55
|
+
const decoded = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'));
|
|
56
|
+
if (decoded && decoded.v === 1 && typeof decoded.key === 'string') return decoded;
|
|
57
|
+
} catch (_) {
|
|
58
|
+
// fall through
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pickFolderEnabled() {
|
|
64
|
+
return process.env.BRIDGE_CONSOLE_PICK_FOLDER !== '0';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function createConsoleServer({ home }) {
|
|
68
|
+
if (typeof home !== 'string' || !home) {
|
|
69
|
+
throw new Error('createConsoleServer requires an explicit home directory');
|
|
70
|
+
}
|
|
71
|
+
const globalDir = globalJournalDir(home);
|
|
72
|
+
const consolePageFile = path.join(__dirname, '..', 'console', 'index.html');
|
|
73
|
+
|
|
74
|
+
const journals = new Map();
|
|
75
|
+
const queries = new Map();
|
|
76
|
+
|
|
77
|
+
function journalAt(dir) {
|
|
78
|
+
let journal = journals.get(dir);
|
|
79
|
+
if (!journal) {
|
|
80
|
+
journal = new Journal(dir);
|
|
81
|
+
journals.set(dir, journal);
|
|
82
|
+
}
|
|
83
|
+
return journal;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function queryAt(dir) {
|
|
87
|
+
let query = queries.get(dir);
|
|
88
|
+
if (!query) {
|
|
89
|
+
query = new ConversationQuery({ journal: journalAt(dir) });
|
|
90
|
+
queries.set(dir, query);
|
|
91
|
+
}
|
|
92
|
+
return query;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function existingJournal(dir) {
|
|
96
|
+
return fs.existsSync(dir) ? journalAt(dir) : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function sourcesForProject(project) {
|
|
100
|
+
if (project.auto) {
|
|
101
|
+
return [{ tag: 'global', dir: globalDir }];
|
|
102
|
+
}
|
|
103
|
+
return [
|
|
104
|
+
{ tag: 'project', dir: projectJournalDir(project) },
|
|
105
|
+
{ tag: 'global', dir: globalDir }
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isConversationEvent(record) {
|
|
110
|
+
return record.schema === EVENT_SCHEMA && (record.eventType === 'user_prompt' || record.eventType === 'assistant_response');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function allTurnsFrom(query, { project, date }) {
|
|
114
|
+
const turns = [];
|
|
115
|
+
let cursor = null;
|
|
116
|
+
for (;;) {
|
|
117
|
+
const page = await query.turns({ project, date, cursor, limit: 200 });
|
|
118
|
+
turns.push(...page.turns);
|
|
119
|
+
if (!page.nextCursor) break;
|
|
120
|
+
cursor = page.nextCursor;
|
|
121
|
+
}
|
|
122
|
+
return turns;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function turnSortKey(turn) {
|
|
126
|
+
const at = (turn.userEvents && turn.userEvents[0] && turn.userEvents[0].capturedAt) || '';
|
|
127
|
+
return `${at}|${turn._src || ''}|${turn.turnId || ''}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function mergedTurns(project, { date }) {
|
|
131
|
+
const merged = [];
|
|
132
|
+
for (const source of sourcesForProject(project)) {
|
|
133
|
+
if (!fs.existsSync(source.dir)) continue;
|
|
134
|
+
const turns = await allTurnsFrom(queryAt(source.dir), { project: project.id, date });
|
|
135
|
+
for (const turn of turns) merged.push({ ...turn, _src: source.tag });
|
|
136
|
+
}
|
|
137
|
+
merged.sort((a, b) => (turnSortKey(a) < turnSortKey(b) ? -1 : turnSortKey(a) > turnSortKey(b) ? 1 : 0));
|
|
138
|
+
return merged;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function datesForProject(project) {
|
|
142
|
+
const byDay = new Map();
|
|
143
|
+
for (const source of sourcesForProject(project)) {
|
|
144
|
+
const journal = existingJournal(source.dir);
|
|
145
|
+
if (!journal) continue;
|
|
146
|
+
const records = await journal.readEvents({
|
|
147
|
+
filter: (record) =>
|
|
148
|
+
isConversationEvent(record) && record.eventType === 'user_prompt' && record.turnId && repoIdentityKey(record.repoIdentity) === project.id
|
|
149
|
+
});
|
|
150
|
+
for (const record of records) {
|
|
151
|
+
const day = localDay(record.capturedAt);
|
|
152
|
+
if (!day) continue;
|
|
153
|
+
const turnIds = byDay.get(day) || new Set();
|
|
154
|
+
turnIds.add(record.turnId);
|
|
155
|
+
byDay.set(day, turnIds);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return [...byDay.entries()]
|
|
159
|
+
.map(([date, turnIds]) => ({ date, turns: turnIds.size }))
|
|
160
|
+
.sort((a, b) => (a.date < b.date ? 1 : -1));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function findProject(id) {
|
|
164
|
+
const registered = projectRegistry.listProjects(home).find((project) => project.id === id);
|
|
165
|
+
if (registered) return { ...registered, auto: false };
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function findAutoProject(id) {
|
|
170
|
+
const journal = existingJournal(globalDir);
|
|
171
|
+
if (!journal) return null;
|
|
172
|
+
const discovered = await queryAt(globalDir).listProjects();
|
|
173
|
+
const match = discovered.find((entry) => repoIdentityKey(entry.repoIdentity) === id);
|
|
174
|
+
if (!match) return null;
|
|
175
|
+
const key = repoIdentityKey(match.repoIdentity);
|
|
176
|
+
return {
|
|
177
|
+
id: key,
|
|
178
|
+
name: path.basename(match.projectPath || key) || key,
|
|
179
|
+
path: match.projectPath || null,
|
|
180
|
+
remote: (match.repoIdentity && match.repoIdentity.remote) || null,
|
|
181
|
+
store: globalDir,
|
|
182
|
+
auto: true,
|
|
183
|
+
lastCapturedAt: match.lastCapturedAt || null
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function resolveProject(id) {
|
|
188
|
+
return (id && (findProject(id) || (await findAutoProject(id)))) || null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function listConsoleProjects() {
|
|
192
|
+
const out = [];
|
|
193
|
+
for (const project of projectRegistry.listProjects(home)) {
|
|
194
|
+
const dates = await datesForProject({ ...project, auto: false });
|
|
195
|
+
const turns = dates.reduce((sum, entry) => sum + entry.turns, 0);
|
|
196
|
+
out.push({
|
|
197
|
+
id: project.id,
|
|
198
|
+
name: project.name,
|
|
199
|
+
path: project.path,
|
|
200
|
+
remote: project.remote || null,
|
|
201
|
+
store: project.store,
|
|
202
|
+
auto: false,
|
|
203
|
+
turns,
|
|
204
|
+
lastCapturedAt: (dates[0] && dates[0].date) || null
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
const journal = existingJournal(globalDir);
|
|
208
|
+
if (journal) {
|
|
209
|
+
const registeredIds = new Set(out.map((project) => project.id));
|
|
210
|
+
const discovered = await queryAt(globalDir).listProjects();
|
|
211
|
+
for (const entry of discovered) {
|
|
212
|
+
const key = repoIdentityKey(entry.repoIdentity);
|
|
213
|
+
if (!key || registeredIds.has(key)) continue;
|
|
214
|
+
out.push({
|
|
215
|
+
id: key,
|
|
216
|
+
name: path.basename(entry.projectPath || key) || key,
|
|
217
|
+
path: entry.projectPath || null,
|
|
218
|
+
remote: (entry.repoIdentity && entry.repoIdentity.remote) || null,
|
|
219
|
+
store: globalDir,
|
|
220
|
+
auto: true,
|
|
221
|
+
turns: entry.eventCount,
|
|
222
|
+
lastCapturedAt: entry.lastCapturedAt || null
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function annotationsFor(project, turns) {
|
|
230
|
+
const shasByTurn = new Map();
|
|
231
|
+
const add = (turnId, sha) => {
|
|
232
|
+
if (!turnId) return;
|
|
233
|
+
const set = shasByTurn.get(turnId) || new Set();
|
|
234
|
+
set.add(sha);
|
|
235
|
+
shasByTurn.set(turnId, set);
|
|
236
|
+
};
|
|
237
|
+
for (const source of sourcesForProject(project)) {
|
|
238
|
+
const journal = existingJournal(source.dir);
|
|
239
|
+
if (!journal) continue;
|
|
240
|
+
const boundaries = await journal.readEvents({ filter: (record) => record.schema === BOUNDARY_SCHEMA });
|
|
241
|
+
for (const boundary of boundaries) {
|
|
242
|
+
if (repoIdentityKey(boundary.repoIdentity) !== project.id) continue;
|
|
243
|
+
const sha = typeof boundary.commitSha === 'string' && /^[0-9a-f]{7,40}$/i.test(boundary.commitSha) ? boundary.commitSha : null;
|
|
244
|
+
if (!sha) continue;
|
|
245
|
+
for (const turnId of boundary.openTurnIdsAtCommit || []) add(turnId, sha);
|
|
246
|
+
// Sequence spans only compare within one journal's sequence space.
|
|
247
|
+
for (const turn of turns) {
|
|
248
|
+
if (turn._src !== source.tag) continue;
|
|
249
|
+
if (turn.startSequence <= boundary.sequence && boundary.sequence <= turn.endSequence) add(turn.turnId, sha);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const annotations = {};
|
|
254
|
+
for (const [turnId, shas] of shasByTurn) {
|
|
255
|
+
annotations[turnId] = { commitShas: [...shas] };
|
|
256
|
+
}
|
|
257
|
+
return annotations;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function sendJson(res, status, payload) {
|
|
261
|
+
const body = JSON.stringify(payload);
|
|
262
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
263
|
+
res.end(body);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---------- project journal maintenance (conveyor semantics) ----------
|
|
267
|
+
|
|
268
|
+
function sealDirOf(project) {
|
|
269
|
+
return path.join(project.store, 'commits');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function maintainProject(project) {
|
|
273
|
+
const journal = existingJournal(projectJournalDir(project));
|
|
274
|
+
if (!journal) return;
|
|
275
|
+
const sealDir = sealDirOf(project);
|
|
276
|
+
try {
|
|
277
|
+
await commitProjection.reconcileTrim({ journal, sealDir });
|
|
278
|
+
} catch (_) {
|
|
279
|
+
// best-effort: the next maintenance pass retries
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
await commitProjection.sealUncommittedTail({ journal, sealDir });
|
|
283
|
+
} catch (_) {
|
|
284
|
+
// best-effort
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function maintainAll() {
|
|
289
|
+
return Promise.all(projectRegistry.listProjects(home).map((entry) => maintainProject({ ...entry, auto: false }))).catch(() => {});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The current conversation of a registered project: turns that live in the
|
|
294
|
+
* project journal after its last commit boundary. Everything earlier is
|
|
295
|
+
* archived under <store>/commits and served by the commit-doc endpoints.
|
|
296
|
+
*/
|
|
297
|
+
async function currentTurns(project, { limit, cursorRaw }) {
|
|
298
|
+
const dir = projectJournalDir(project);
|
|
299
|
+
const journal = existingJournal(dir);
|
|
300
|
+
if (!journal) {
|
|
301
|
+
return { project: project.id, date: null, turns: [], nextCursor: null, totalTurns: 0, annotations: {} };
|
|
302
|
+
}
|
|
303
|
+
const boundaries = await journal.readEvents({
|
|
304
|
+
filter: (record) => record.schema === BOUNDARY_SCHEMA && repoIdentityKey(record.repoIdentity) === project.id
|
|
305
|
+
});
|
|
306
|
+
const lastBoundary = boundaries.reduce((max, b) => Math.max(max, b.sequence), 0);
|
|
307
|
+
const records = await journal.readEvents({ fromSequence: lastBoundary + 1 });
|
|
308
|
+
const turns = queryAt(dir)._projectTurns(records.filter(isConversationEvent));
|
|
309
|
+
turns.sort((a, b) => a.startSequence - b.startSequence);
|
|
310
|
+
let start = 0;
|
|
311
|
+
if (cursorRaw) {
|
|
312
|
+
let decoded = null;
|
|
313
|
+
try {
|
|
314
|
+
decoded = JSON.parse(Buffer.from(String(cursorRaw), 'base64url').toString('utf8'));
|
|
315
|
+
} catch (_) {
|
|
316
|
+
decoded = null;
|
|
317
|
+
}
|
|
318
|
+
if (!decoded || decoded.v !== 1 || typeof decoded.seq !== 'number') return null;
|
|
319
|
+
start = turns.findIndex((turn) => turn.startSequence > decoded.seq);
|
|
320
|
+
if (start === -1) start = turns.length;
|
|
321
|
+
}
|
|
322
|
+
const page = turns.slice(start, start + limit);
|
|
323
|
+
return {
|
|
324
|
+
project: project.id,
|
|
325
|
+
date: null,
|
|
326
|
+
turns: page,
|
|
327
|
+
nextCursor: start + limit < turns.length && page.length ? encodeCursor({ v: 1, seq: page[page.length - 1].startSequence }) : null,
|
|
328
|
+
totalTurns: turns.length,
|
|
329
|
+
annotations: {}
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---------- commit documents (the per-commit archive) ----------
|
|
334
|
+
|
|
335
|
+
function readDocHead(file, maxBytes = 4096) {
|
|
336
|
+
const fd = fs.openSync(file, 'r');
|
|
337
|
+
try {
|
|
338
|
+
const buf = Buffer.alloc(maxBytes);
|
|
339
|
+
const read = fs.readSync(fd, buf, 0, maxBytes, 0);
|
|
340
|
+
return buf.toString('utf8', 0, read);
|
|
341
|
+
} finally {
|
|
342
|
+
fs.closeSync(fd);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function parseFrontmatter(text) {
|
|
347
|
+
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
348
|
+
if (!match) return {};
|
|
349
|
+
const out = {};
|
|
350
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
351
|
+
const kv = line.match(/^([A-Za-z]+):\s*(.*)$/);
|
|
352
|
+
if (kv) out[kv[1]] = kv[2].replace(/^'/, '').replace(/'$/, '');
|
|
353
|
+
}
|
|
354
|
+
return out;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function listCommitDocs(project) {
|
|
358
|
+
if (project.auto) return [];
|
|
359
|
+
const dir = sealDirOf(project);
|
|
360
|
+
let names;
|
|
361
|
+
try {
|
|
362
|
+
names = fs.readdirSync(dir);
|
|
363
|
+
} catch (_) {
|
|
364
|
+
return [];
|
|
365
|
+
}
|
|
366
|
+
const docs = [];
|
|
367
|
+
for (const name of names) {
|
|
368
|
+
if (!name.endsWith('.md')) continue;
|
|
369
|
+
const fm = parseFrontmatter(readDocHead(path.join(dir, name)));
|
|
370
|
+
docs.push({
|
|
371
|
+
file: name,
|
|
372
|
+
sha: /^[0-9a-f]{7,64}$/.test(String(fm.commitSha || '')) ? fm.commitSha : null,
|
|
373
|
+
date: fm.committedAt || fm.sealedAt || null,
|
|
374
|
+
subject: fm.subject || (String(fm.uncommitted) === 'true' ? '未提交的对话' : name.replace(/\.md$/, '')),
|
|
375
|
+
turnCount: Number(fm.turnCount) || 0,
|
|
376
|
+
uncommitted: String(fm.uncommitted) === 'true'
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
docs.sort((a, b) => ((a.date || '') < (b.date || '') ? 1 : -1));
|
|
380
|
+
return docs;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function sendPage(res) {
|
|
384
|
+
let html;
|
|
385
|
+
try {
|
|
386
|
+
html = fs.readFileSync(consolePageFile);
|
|
387
|
+
} catch (_) {
|
|
388
|
+
sendJson(res, 500, { error: { code: 'CONSOLE_PAGE_MISSING', message: `console page not found: ${consolePageFile}` } });
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
392
|
+
res.end(html);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function readBody(req) {
|
|
396
|
+
return new Promise((resolve, reject) => {
|
|
397
|
+
let size = 0;
|
|
398
|
+
const chunks = [];
|
|
399
|
+
req.on('data', (chunk) => {
|
|
400
|
+
size += chunk.length;
|
|
401
|
+
if (size > MAX_BODY_BYTES) {
|
|
402
|
+
reject(Object.assign(new Error('request body too large'), { code: 'BODY_TOO_LARGE' }));
|
|
403
|
+
req.destroy();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
chunks.push(chunk);
|
|
407
|
+
});
|
|
408
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
409
|
+
req.on('error', reject);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function handleApi(req, res, url) {
|
|
414
|
+
const route = `${req.method} ${url.pathname}`;
|
|
415
|
+
|
|
416
|
+
if (route === 'GET /api/health') {
|
|
417
|
+
const journal = existingJournal(globalDir);
|
|
418
|
+
const bounds = journal ? await journal.getBounds() : { firstSequence: 0, lastSequence: 0 };
|
|
419
|
+
sendJson(res, 200, {
|
|
420
|
+
ok: true,
|
|
421
|
+
home,
|
|
422
|
+
registeredProjects: projectRegistry.listProjects(home).length,
|
|
423
|
+
firstSequence: bounds.firstSequence,
|
|
424
|
+
lastSequence: bounds.lastSequence
|
|
425
|
+
});
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (route === 'GET /api/system/pick-folder') {
|
|
430
|
+
if (!pickFolderEnabled()) {
|
|
431
|
+
sendJson(res, 501, {
|
|
432
|
+
error: {
|
|
433
|
+
code: 'PICK_FOLDER_UNSUPPORTED',
|
|
434
|
+
message: 'folder picker disabled (BRIDGE_CONSOLE_PICK_FOLDER=0); type the path instead'
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
// Optional `win=x,y,w,h` anchors the dialog on the calling browser
|
|
440
|
+
// window (unified picker contract).
|
|
441
|
+
const parts = (url.searchParams.get('win') || '').split(',').map(Number);
|
|
442
|
+
const windowRect = parts.length === 4 && parts.every(Number.isFinite)
|
|
443
|
+
? { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }
|
|
444
|
+
: undefined;
|
|
445
|
+
try {
|
|
446
|
+
const picked = await pickFolder({ title: '选择项目文件夹', windowRect });
|
|
447
|
+
sendJson(res, 200, picked);
|
|
448
|
+
} catch (err) {
|
|
449
|
+
const status = (err && err.status) || 500;
|
|
450
|
+
sendJson(res, status, {
|
|
451
|
+
error: {
|
|
452
|
+
code: status === 501 ? 'PICK_FOLDER_UNSUPPORTED' : 'PICK_FOLDER_FAILED',
|
|
453
|
+
message: (err && err.message) || 'failed to open the folder picker'
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (route === 'GET /api/system/agents') {
|
|
461
|
+
const agents = await detectAgents();
|
|
462
|
+
let activeRuntimeVersion = null;
|
|
463
|
+
let runtimeVersions = [];
|
|
464
|
+
const runtimeRoot = path.join(home, 'runtime');
|
|
465
|
+
try {
|
|
466
|
+
const active = JSON.parse(fs.readFileSync(path.join(home, 'active-runtime.json'), 'utf8'));
|
|
467
|
+
if (active && typeof active.version === 'string') activeRuntimeVersion = active.version;
|
|
468
|
+
} catch (_) {
|
|
469
|
+
activeRuntimeVersion = null;
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
runtimeVersions = fs
|
|
473
|
+
.readdirSync(runtimeRoot)
|
|
474
|
+
.filter((name) => fs.statSync(path.join(runtimeRoot, name)).isDirectory())
|
|
475
|
+
.sort();
|
|
476
|
+
} catch (_) {
|
|
477
|
+
runtimeVersions = [];
|
|
478
|
+
}
|
|
479
|
+
let consumers = [];
|
|
480
|
+
try {
|
|
481
|
+
consumers = (await new ConsumerRegistry(home).getConsumers()).map((consumer) => ({
|
|
482
|
+
name: consumer.name,
|
|
483
|
+
lastSeenAt: consumer.lastSeenAt || null,
|
|
484
|
+
notifyUrl: (consumer.meta && consumer.meta.notifyUrl) || null
|
|
485
|
+
}));
|
|
486
|
+
} catch (_) {
|
|
487
|
+
consumers = [];
|
|
488
|
+
}
|
|
489
|
+
sendJson(res, 200, {
|
|
490
|
+
agents,
|
|
491
|
+
bridge: {
|
|
492
|
+
home,
|
|
493
|
+
coreVersion: corePackage.version,
|
|
494
|
+
activeRuntimeVersion,
|
|
495
|
+
runtimeVersions,
|
|
496
|
+
upgradeAvailable: Boolean(activeRuntimeVersion) && semver.compare(activeRuntimeVersion, corePackage.version) === -1
|
|
497
|
+
},
|
|
498
|
+
consumers
|
|
499
|
+
});
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (route === 'POST /api/system/upgrade-runtime') {
|
|
504
|
+
try {
|
|
505
|
+
const result = await ensureRuntimeHome({ homeDir: home });
|
|
506
|
+
if (result && result.conflict) {
|
|
507
|
+
sendJson(res, 409, {
|
|
508
|
+
error: {
|
|
509
|
+
code: 'RUNTIME_MAJOR_CONFLICT',
|
|
510
|
+
message: `active runtime ${result.activeVersion} and requested ${result.requestedVersion} differ in major version`
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
sendJson(res, 200, { ok: true, action: result.action, activeVersion: result.activeVersion });
|
|
516
|
+
} catch (err) {
|
|
517
|
+
sendJson(res, 500, { error: { code: 'RUNTIME_UPGRADE_FAILED', message: (err && err.message) || 'upgrade failed' } });
|
|
518
|
+
}
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (route === 'GET /api/projects') {
|
|
523
|
+
sendJson(res, 200, { projects: await listConsoleProjects() });
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (route === 'POST /api/projects') {
|
|
528
|
+
let parsed = {};
|
|
529
|
+
try {
|
|
530
|
+
parsed = JSON.parse(await readBody(req) || '{}');
|
|
531
|
+
} catch (_) {
|
|
532
|
+
sendJson(res, 400, { error: { code: 'BODY_INVALID', message: 'request body must be JSON' } });
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
const project = await projectRegistry.addProject({ home, path: parsed.path, store: parsed.store });
|
|
537
|
+
sendJson(res, 201, { project: { ...project, auto: false } });
|
|
538
|
+
} catch (err) {
|
|
539
|
+
const statusByCode = {
|
|
540
|
+
PROJECT_PATH_REQUIRED: 400,
|
|
541
|
+
PROJECT_DIR_INVALID: 400,
|
|
542
|
+
PROJECT_STORE_INVALID: 400,
|
|
543
|
+
PROJECT_NOT_GIT: 422,
|
|
544
|
+
PROJECT_DUPLICATE: 409,
|
|
545
|
+
PROJECT_STORE_CONFLICT: 409
|
|
546
|
+
};
|
|
547
|
+
const status = statusByCode[err && err.code] || 500;
|
|
548
|
+
sendJson(res, status, { error: { code: (err && err.code) || 'INTERNAL', message: err && err.message } });
|
|
549
|
+
}
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (route === 'DELETE /api/projects') {
|
|
554
|
+
const id = url.searchParams.get('id');
|
|
555
|
+
try {
|
|
556
|
+
const removed = await projectRegistry.removeProject({ home, id });
|
|
557
|
+
sendJson(res, 200, { removed });
|
|
558
|
+
} catch (err) {
|
|
559
|
+
const status = err && err.code === 'PROJECT_NOT_FOUND' ? 404 : err && err.code === 'PROJECT_ID_REQUIRED' ? 400 : 500;
|
|
560
|
+
sendJson(res, status, { error: { code: (err && err.code) || 'INTERNAL', message: err && err.message } });
|
|
561
|
+
}
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Single commit document, fetched lazily by the UI when a history entry
|
|
566
|
+
// is opened. Only names the sealer could have produced are accepted.
|
|
567
|
+
if (req.method === 'GET' && url.pathname.startsWith('/api/commits/')) {
|
|
568
|
+
let docName;
|
|
569
|
+
try {
|
|
570
|
+
docName = decodeURIComponent(url.pathname.slice('/api/commits/'.length));
|
|
571
|
+
} catch (_) {
|
|
572
|
+
docName = '';
|
|
573
|
+
}
|
|
574
|
+
if (!COMMIT_DOC_PATTERN.test(docName)) {
|
|
575
|
+
sendJson(res, 400, { error: { code: 'DOC_NAME_INVALID', message: 'invalid commit document name' } });
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const project = await resolveProject(url.searchParams.get('project'));
|
|
579
|
+
if (!project) {
|
|
580
|
+
sendJson(res, 404, { error: { code: 'PROJECT_NOT_FOUND', message: `unknown project: ${url.searchParams.get('project')}` } });
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
if (project.auto) {
|
|
584
|
+
sendJson(res, 404, { error: { code: 'COMMITS_UNSUPPORTED', message: 'auto-discovered projects keep no commit archive' } });
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const file = path.join(sealDirOf(project), docName);
|
|
588
|
+
let text;
|
|
589
|
+
try {
|
|
590
|
+
text = fs.readFileSync(file, 'utf8');
|
|
591
|
+
} catch (_) {
|
|
592
|
+
sendJson(res, 404, { error: { code: 'DOC_NOT_FOUND', message: `no sealed document: ${docName}` } });
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'no-store' });
|
|
596
|
+
res.end(text);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const projectId = url.searchParams.get('project');
|
|
601
|
+
if (
|
|
602
|
+
route === 'GET /api/dates' ||
|
|
603
|
+
route === 'GET /api/turns' ||
|
|
604
|
+
route === 'GET /api/commits' ||
|
|
605
|
+
route === 'GET /api/sessions' ||
|
|
606
|
+
route === 'GET /api/search'
|
|
607
|
+
) {
|
|
608
|
+
const project = await resolveProject(projectId);
|
|
609
|
+
if (!project) {
|
|
610
|
+
sendJson(res, 404, { error: { code: 'PROJECT_NOT_FOUND', message: `unknown project: ${projectId}` } });
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (route === 'GET /api/dates') {
|
|
615
|
+
sendJson(res, 200, { project: project.id, dates: await datesForProject(project) });
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (route === 'GET /api/turns') {
|
|
620
|
+
const limit = Math.max(1, Math.min(Number(url.searchParams.get('limit')) || 50, 200));
|
|
621
|
+
if (!project.auto) {
|
|
622
|
+
// Registered projects: the journal is a conveyor — serve only the
|
|
623
|
+
// current (uncommitted) conversation and keep it maintained.
|
|
624
|
+
await maintainProject(project);
|
|
625
|
+
const current = await currentTurns(project, { limit, cursorRaw: url.searchParams.get('cursor') });
|
|
626
|
+
if (!current) {
|
|
627
|
+
sendJson(res, 400, { error: { code: 'CURSOR_INVALID', message: 'cursor is invalid; restart the query' } });
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
sendJson(res, 200, current);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const date = url.searchParams.get('date') || null;
|
|
634
|
+
const merged = await mergedTurns(project, { date });
|
|
635
|
+
const cursor = url.searchParams.get('cursor');
|
|
636
|
+
let start = 0;
|
|
637
|
+
if (cursor) {
|
|
638
|
+
const decoded = decodeCursor(cursor);
|
|
639
|
+
if (!decoded) {
|
|
640
|
+
sendJson(res, 400, { error: { code: 'CURSOR_INVALID', message: 'cursor is invalid; restart the query' } });
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
start = merged.findIndex((turn) => turnSortKey(turn) > decoded.key);
|
|
644
|
+
if (start === -1) start = merged.length;
|
|
645
|
+
}
|
|
646
|
+
const page = merged.slice(start, start + limit);
|
|
647
|
+
sendJson(res, 200, {
|
|
648
|
+
project: project.id,
|
|
649
|
+
date,
|
|
650
|
+
turns: page,
|
|
651
|
+
nextCursor: start + limit < merged.length ? encodeCursor({ v: 1, key: turnSortKey(merged[start + limit - 1]) }) : null,
|
|
652
|
+
totalTurns: merged.length,
|
|
653
|
+
annotations: await annotationsFor(project, page)
|
|
654
|
+
});
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (route === 'GET /api/commits') {
|
|
659
|
+
sendJson(res, 200, { project: project.id, commits: await listCommitDocs(project) });
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (route === 'GET /api/sessions') {
|
|
664
|
+
const sessions = [];
|
|
665
|
+
for (const source of sourcesForProject(project)) {
|
|
666
|
+
if (!fs.existsSync(source.dir)) continue;
|
|
667
|
+
sessions.push(...(await queryAt(source.dir).listSessions({ project: project.id })));
|
|
668
|
+
}
|
|
669
|
+
sessions.sort((a, b) => b.lastSequence - a.lastSequence);
|
|
670
|
+
sendJson(res, 200, { project: project.id, sessions });
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (route === 'GET /api/search') {
|
|
675
|
+
const q = url.searchParams.get('q') || '';
|
|
676
|
+
const limit = Math.max(1, Math.min(Number(url.searchParams.get('limit')) || 20, 100));
|
|
677
|
+
const matches = [];
|
|
678
|
+
for (const source of sourcesForProject(project)) {
|
|
679
|
+
if (!fs.existsSync(source.dir)) continue;
|
|
680
|
+
const result = await queryAt(source.dir).searchConversations({ project: project.id, q, limit });
|
|
681
|
+
matches.push(...result.matches.map((match) => ({ ...match, _src: source.tag })));
|
|
682
|
+
}
|
|
683
|
+
matches.sort((a, b) => (a.capturedAt || '') < (b.capturedAt || '') ? 1 : -1);
|
|
684
|
+
sendJson(res, 200, { project: project.id, q, matches: matches.slice(0, limit) });
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
sendJson(res, 404, { error: { code: 'NOT_FOUND', message: route } });
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Startup maintenance: reconcile trim watermarks and fire age fuses. Runs
|
|
693
|
+
// in the background; failures never surface to requests.
|
|
694
|
+
maintainAll();
|
|
695
|
+
|
|
696
|
+
return async function handler(req, res) {
|
|
697
|
+
try {
|
|
698
|
+
const url = new URL(req.url, 'http://localhost');
|
|
699
|
+
if (url.pathname === '/' || url.pathname === '/index.html') {
|
|
700
|
+
sendPage(res);
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (url.pathname === '/favicon.ico') {
|
|
704
|
+
res.writeHead(204);
|
|
705
|
+
res.end();
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (url.pathname.startsWith('/api/')) {
|
|
709
|
+
await handleApi(req, res, url);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
sendJson(res, 404, { error: { code: 'NOT_FOUND', message: `${req.method} ${url.pathname}` } });
|
|
713
|
+
} catch (err) {
|
|
714
|
+
sendJson(res, 500, { error: { code: 'INTERNAL', message: (err && err.message) || 'internal error' } });
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
module.exports = { createConsoleServer, localDay };
|