trellis 2.0.7 → 2.0.8
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/dist/cli/index.js +378 -28
- package/dist/core/index.js +5 -1
- package/dist/decisions/index.js +5 -2
- package/dist/{index-3s0eak0p.js → index-3ejh8k6v.js} +26 -3
- package/dist/{index-gkvhzm9f.js → index-5bhe57y9.js} +6 -1
- package/dist/{index-8pce39mh.js → index-65z0xfjw.js} +17 -3
- package/dist/{index-gnw8d7d6.js → index-k5kf7sd0.js} +32 -3
- package/dist/{index-1j1anhmr.js → index-s603ev6w.js} +489 -335
- package/dist/{index-fd4e26s4.js → index-v9b4hqa7.js} +23 -15
- package/dist/index.js +15 -7
- package/dist/ui/client.html +695 -0
- package/dist/vcs/index.js +3 -3
- package/package.json +2 -2
- package/src/cli/index.ts +78 -1
- package/src/engine.ts +45 -3
- package/src/ui/client.html +695 -0
- package/src/ui/server.ts +419 -0
- package/src/watcher/fs-watcher.ts +41 -3
package/src/ui/server.ts
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trellis UI Server
|
|
3
|
+
*
|
|
4
|
+
* Lightweight Bun HTTP server that exposes the TrellisVCS engine
|
|
5
|
+
* as a JSON API and serves a single-file force-graph visualization.
|
|
6
|
+
*
|
|
7
|
+
* Endpoints:
|
|
8
|
+
* GET / → client.html
|
|
9
|
+
* GET /api/graph → full graph (nodes + edges)
|
|
10
|
+
* GET /api/search → semantic search (?q=...&limit=10&type=...)
|
|
11
|
+
* GET /api/node/:id → node detail
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, existsSync } from 'fs';
|
|
15
|
+
import { join, dirname } from 'path';
|
|
16
|
+
import { TrellisVcsEngine } from '../engine.js';
|
|
17
|
+
import {
|
|
18
|
+
buildRefIndex,
|
|
19
|
+
createResolverContext,
|
|
20
|
+
getOutgoingRefs,
|
|
21
|
+
getReferencedEntities,
|
|
22
|
+
getBacklinks,
|
|
23
|
+
} from '../links/index.js';
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Types
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export interface GraphNode {
|
|
30
|
+
id: string;
|
|
31
|
+
label: string;
|
|
32
|
+
type: 'file' | 'milestone' | 'issue' | 'branch';
|
|
33
|
+
meta: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface GraphEdge {
|
|
37
|
+
source: string;
|
|
38
|
+
target: string;
|
|
39
|
+
type: 'milestone_file' | 'issue_branch' | 'wikilink' | 'causal';
|
|
40
|
+
label?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface GraphData {
|
|
44
|
+
nodes: GraphNode[];
|
|
45
|
+
edges: GraphEdge[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Graph builder
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
function buildGraph(engine: TrellisVcsEngine): GraphData {
|
|
53
|
+
const nodes: GraphNode[] = [];
|
|
54
|
+
const edges: GraphEdge[] = [];
|
|
55
|
+
const nodeIds = new Set<string>();
|
|
56
|
+
|
|
57
|
+
// --- Files ---
|
|
58
|
+
const files = engine.trackedFiles();
|
|
59
|
+
for (const f of files) {
|
|
60
|
+
const id = `file:${f.path}`;
|
|
61
|
+
nodes.push({
|
|
62
|
+
id,
|
|
63
|
+
label: f.path,
|
|
64
|
+
type: 'file',
|
|
65
|
+
meta: { contentHash: f.contentHash },
|
|
66
|
+
});
|
|
67
|
+
nodeIds.add(id);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- Milestones ---
|
|
71
|
+
const milestones = engine.listMilestones();
|
|
72
|
+
for (const m of milestones) {
|
|
73
|
+
const id = `milestone:${m.id}`;
|
|
74
|
+
nodes.push({
|
|
75
|
+
id,
|
|
76
|
+
label: m.message ?? m.id,
|
|
77
|
+
type: 'milestone',
|
|
78
|
+
meta: {
|
|
79
|
+
createdAt: m.createdAt,
|
|
80
|
+
affectedFiles: m.affectedFiles,
|
|
81
|
+
fromOpHash: m.fromOpHash,
|
|
82
|
+
toOpHash: m.toOpHash,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
nodeIds.add(id);
|
|
86
|
+
|
|
87
|
+
// Edges: milestone → affected files
|
|
88
|
+
for (const fp of m.affectedFiles ?? []) {
|
|
89
|
+
const fileId = `file:${fp}`;
|
|
90
|
+
if (nodeIds.has(fileId)) {
|
|
91
|
+
edges.push({
|
|
92
|
+
source: id,
|
|
93
|
+
target: fileId,
|
|
94
|
+
type: 'milestone_file',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- Issues ---
|
|
101
|
+
const issues = engine.listIssues();
|
|
102
|
+
for (const iss of issues) {
|
|
103
|
+
const id = `issue:${iss.id}`;
|
|
104
|
+
nodes.push({
|
|
105
|
+
id,
|
|
106
|
+
label: iss.title ?? iss.id,
|
|
107
|
+
type: 'issue',
|
|
108
|
+
meta: {
|
|
109
|
+
status: iss.status,
|
|
110
|
+
priority: iss.priority,
|
|
111
|
+
labels: iss.labels,
|
|
112
|
+
assignee: iss.assignee,
|
|
113
|
+
createdAt: iss.createdAt,
|
|
114
|
+
description: iss.description,
|
|
115
|
+
criteria: iss.criteria,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
nodeIds.add(id);
|
|
119
|
+
|
|
120
|
+
// Edge: issue → its branch
|
|
121
|
+
if (iss.branchName) {
|
|
122
|
+
const branchId = `branch:${iss.branchName}`;
|
|
123
|
+
if (nodeIds.has(branchId)) {
|
|
124
|
+
edges.push({
|
|
125
|
+
source: id,
|
|
126
|
+
target: branchId,
|
|
127
|
+
type: 'issue_branch',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- Branches ---
|
|
134
|
+
const branches = engine.listBranches();
|
|
135
|
+
for (const b of branches) {
|
|
136
|
+
const id = `branch:${b.name}`;
|
|
137
|
+
if (!nodeIds.has(id)) {
|
|
138
|
+
nodes.push({
|
|
139
|
+
id,
|
|
140
|
+
label: b.name,
|
|
141
|
+
type: 'branch',
|
|
142
|
+
meta: {
|
|
143
|
+
isCurrent: b.isCurrent,
|
|
144
|
+
createdAt: b.createdAt,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
nodeIds.add(id);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Link issues that reference this branch
|
|
151
|
+
for (const iss of issues) {
|
|
152
|
+
if (iss.branchName === b.name) {
|
|
153
|
+
edges.push({
|
|
154
|
+
source: `issue:${iss.id}`,
|
|
155
|
+
target: id,
|
|
156
|
+
type: 'issue_branch',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// --- Wiki-links (if markdown files exist) ---
|
|
163
|
+
try {
|
|
164
|
+
const mdFiles: Array<{ path: string; content: string }> = [];
|
|
165
|
+
for (const f of files) {
|
|
166
|
+
if (f.path.endsWith('.md')) {
|
|
167
|
+
const absPath = join(engine.getRootPath(), f.path);
|
|
168
|
+
if (existsSync(absPath)) {
|
|
169
|
+
mdFiles.push({
|
|
170
|
+
path: f.path,
|
|
171
|
+
content: readFileSync(absPath, 'utf-8'),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (mdFiles.length > 0) {
|
|
178
|
+
const ctx = createResolverContext({
|
|
179
|
+
trackedFiles: () => files,
|
|
180
|
+
listIssues: () => issues as any,
|
|
181
|
+
listMilestones: () => milestones as any,
|
|
182
|
+
} as any);
|
|
183
|
+
|
|
184
|
+
const refIndex = buildRefIndex(mdFiles, ctx);
|
|
185
|
+
|
|
186
|
+
for (const [filePath, refs] of refIndex.outgoing) {
|
|
187
|
+
const sourceId = `file:${filePath}`;
|
|
188
|
+
if (!nodeIds.has(sourceId)) continue;
|
|
189
|
+
for (const ref of refs) {
|
|
190
|
+
const targetId = `${ref.namespace}:${ref.target}`;
|
|
191
|
+
// Normalize to our node IDs
|
|
192
|
+
const candidateIds = [
|
|
193
|
+
targetId,
|
|
194
|
+
`file:${ref.target}`,
|
|
195
|
+
`issue:${ref.target}`,
|
|
196
|
+
`milestone:${ref.target}`,
|
|
197
|
+
];
|
|
198
|
+
for (const cid of candidateIds) {
|
|
199
|
+
if (nodeIds.has(cid) && cid !== sourceId) {
|
|
200
|
+
edges.push({
|
|
201
|
+
source: sourceId,
|
|
202
|
+
target: cid,
|
|
203
|
+
type: 'wikilink',
|
|
204
|
+
label: ref.target,
|
|
205
|
+
});
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// Wiki-link indexing is best-effort
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { nodes, edges };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Node detail
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
function getNodeDetail(
|
|
224
|
+
engine: TrellisVcsEngine,
|
|
225
|
+
nodeId: string,
|
|
226
|
+
): Record<string, unknown> | null {
|
|
227
|
+
const [type, ...rest] = nodeId.split(':');
|
|
228
|
+
const id = rest.join(':');
|
|
229
|
+
|
|
230
|
+
switch (type) {
|
|
231
|
+
case 'file': {
|
|
232
|
+
const files = engine.trackedFiles();
|
|
233
|
+
const file = files.find((f) => f.path === id);
|
|
234
|
+
if (!file) return null;
|
|
235
|
+
// Get recent ops for this file
|
|
236
|
+
const ops = engine.log({ filePath: id, limit: 10 });
|
|
237
|
+
return {
|
|
238
|
+
type: 'file',
|
|
239
|
+
path: id,
|
|
240
|
+
contentHash: file.contentHash,
|
|
241
|
+
recentOps: ops.map((o) => ({
|
|
242
|
+
kind: o.kind,
|
|
243
|
+
timestamp: o.timestamp,
|
|
244
|
+
hash: o.hash.slice(0, 24),
|
|
245
|
+
})),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
case 'milestone': {
|
|
249
|
+
const milestones = engine.listMilestones();
|
|
250
|
+
const m = milestones.find((ms) => ms.id === id);
|
|
251
|
+
if (!m) return null;
|
|
252
|
+
return { type: 'milestone', ...m };
|
|
253
|
+
}
|
|
254
|
+
case 'issue': {
|
|
255
|
+
const issue = engine.getIssue(id);
|
|
256
|
+
if (!issue) return null;
|
|
257
|
+
return { type: 'issue', ...issue };
|
|
258
|
+
}
|
|
259
|
+
case 'branch': {
|
|
260
|
+
const branches = engine.listBranches();
|
|
261
|
+
const b = branches.find((br) => br.name === id);
|
|
262
|
+
if (!b) return null;
|
|
263
|
+
return { type: 'branch', ...b };
|
|
264
|
+
}
|
|
265
|
+
default:
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Server
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
export interface UIServerOptions {
|
|
275
|
+
rootPath: string;
|
|
276
|
+
port?: number;
|
|
277
|
+
open?: boolean;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export async function startUIServer(opts: UIServerOptions): Promise<{
|
|
281
|
+
port: number;
|
|
282
|
+
stop: () => void;
|
|
283
|
+
}> {
|
|
284
|
+
const engine = new TrellisVcsEngine({ rootPath: opts.rootPath });
|
|
285
|
+
engine.open();
|
|
286
|
+
|
|
287
|
+
const clientHtml = readFileSync(
|
|
288
|
+
join(dirname(new URL(import.meta.url).pathname), 'client.html'),
|
|
289
|
+
'utf-8',
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
// Lazy-load embedding manager for search
|
|
293
|
+
let embeddingManager: any = null;
|
|
294
|
+
function getEmbeddingManager() {
|
|
295
|
+
if (!embeddingManager) {
|
|
296
|
+
try {
|
|
297
|
+
const { EmbeddingManager } = require('../embeddings/index.js');
|
|
298
|
+
const dbPath = join(opts.rootPath, '.trellis', 'embeddings.db');
|
|
299
|
+
if (existsSync(dbPath)) {
|
|
300
|
+
embeddingManager = new EmbeddingManager(dbPath);
|
|
301
|
+
}
|
|
302
|
+
} catch {
|
|
303
|
+
// Embeddings not available
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return embeddingManager;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const requestedPort = opts.port ?? 3333;
|
|
310
|
+
|
|
311
|
+
const server = Bun.serve({
|
|
312
|
+
port: requestedPort,
|
|
313
|
+
async fetch(req) {
|
|
314
|
+
const url = new URL(req.url);
|
|
315
|
+
const path = url.pathname;
|
|
316
|
+
|
|
317
|
+
// CORS headers
|
|
318
|
+
const headers = {
|
|
319
|
+
'Access-Control-Allow-Origin': '*',
|
|
320
|
+
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
|
321
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
if (req.method === 'OPTIONS') {
|
|
325
|
+
return new Response(null, { status: 204, headers });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// --- API Routes ---
|
|
329
|
+
|
|
330
|
+
if (path === '/api/graph') {
|
|
331
|
+
const graph = buildGraph(engine);
|
|
332
|
+
return Response.json(graph, { headers });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (path === '/api/search') {
|
|
336
|
+
const query = url.searchParams.get('q');
|
|
337
|
+
if (!query) {
|
|
338
|
+
return Response.json(
|
|
339
|
+
{ error: 'Missing ?q= parameter' },
|
|
340
|
+
{ status: 400, headers },
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
const limit = parseInt(url.searchParams.get('limit') ?? '10', 10);
|
|
344
|
+
const typeFilter = url.searchParams.get('type');
|
|
345
|
+
|
|
346
|
+
const mgr = getEmbeddingManager();
|
|
347
|
+
if (!mgr) {
|
|
348
|
+
return Response.json(
|
|
349
|
+
{
|
|
350
|
+
results: [],
|
|
351
|
+
message: 'No embedding index. Run `trellis reindex` first.',
|
|
352
|
+
},
|
|
353
|
+
{ headers },
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
const searchOpts: any = { limit };
|
|
359
|
+
if (typeFilter) {
|
|
360
|
+
searchOpts.types = typeFilter
|
|
361
|
+
.split(',')
|
|
362
|
+
.map((t: string) => t.trim());
|
|
363
|
+
}
|
|
364
|
+
const results = await mgr.search(query, searchOpts);
|
|
365
|
+
return Response.json(
|
|
366
|
+
{
|
|
367
|
+
results: results.map((r: any) => ({
|
|
368
|
+
score: r.score,
|
|
369
|
+
chunkType: r.chunk.chunkType,
|
|
370
|
+
filePath: r.chunk.filePath,
|
|
371
|
+
entityId: r.chunk.entityId,
|
|
372
|
+
content: r.chunk.content,
|
|
373
|
+
})),
|
|
374
|
+
},
|
|
375
|
+
{ headers },
|
|
376
|
+
);
|
|
377
|
+
} catch (err: any) {
|
|
378
|
+
return Response.json(
|
|
379
|
+
{ error: err.message },
|
|
380
|
+
{ status: 500, headers },
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (path.startsWith('/api/node/')) {
|
|
386
|
+
const nodeId = decodeURIComponent(path.slice('/api/node/'.length));
|
|
387
|
+
const detail = getNodeDetail(engine, nodeId);
|
|
388
|
+
if (!detail) {
|
|
389
|
+
return Response.json(
|
|
390
|
+
{ error: 'Node not found' },
|
|
391
|
+
{ status: 404, headers },
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
return Response.json(detail, { headers });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// --- Static ---
|
|
398
|
+
if (path === '/' || path === '/index.html') {
|
|
399
|
+
return new Response(clientHtml, {
|
|
400
|
+
headers: { ...headers, 'Content-Type': 'text/html; charset=utf-8' },
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return new Response('Not Found', { status: 404, headers });
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
port: server.port as number,
|
|
410
|
+
stop: () => {
|
|
411
|
+
server.stop();
|
|
412
|
+
if (embeddingManager) {
|
|
413
|
+
try {
|
|
414
|
+
embeddingManager.close();
|
|
415
|
+
} catch {}
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
}
|
|
@@ -18,6 +18,13 @@ export interface FileWatcherConfig {
|
|
|
18
18
|
onEvent: (event: FileChangeEvent) => void | Promise<void>;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export interface ScanProgress {
|
|
22
|
+
phase: 'discovering' | 'hashing' | 'done';
|
|
23
|
+
current: number;
|
|
24
|
+
total: number;
|
|
25
|
+
message: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
21
28
|
/**
|
|
22
29
|
* Computes SHA-256 content hash for a file.
|
|
23
30
|
*/
|
|
@@ -59,11 +66,26 @@ export class FileWatcher {
|
|
|
59
66
|
* Scans the directory tree and builds an initial map of all tracked files.
|
|
60
67
|
* Returns the list of FileChangeEvents for the initial state (all adds).
|
|
61
68
|
*/
|
|
62
|
-
async scan(
|
|
69
|
+
async scan(opts?: {
|
|
70
|
+
onProgress?: (progress: ScanProgress) => void;
|
|
71
|
+
}): Promise<FileChangeEvent[]> {
|
|
63
72
|
const events: FileChangeEvent[] = [];
|
|
73
|
+
opts?.onProgress?.({
|
|
74
|
+
phase: 'discovering',
|
|
75
|
+
current: 0,
|
|
76
|
+
total: 0,
|
|
77
|
+
message: 'Discovering existing files…',
|
|
78
|
+
});
|
|
64
79
|
const entries = await this.walkDir(this.config.rootPath);
|
|
65
|
-
|
|
66
|
-
|
|
80
|
+
opts?.onProgress?.({
|
|
81
|
+
phase: 'hashing',
|
|
82
|
+
current: 0,
|
|
83
|
+
total: entries.length,
|
|
84
|
+
message: `Hashing ${entries.length} existing files…`,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
for (let i = 0; i < entries.length; i++) {
|
|
88
|
+
const absPath = entries[i];
|
|
67
89
|
const relPath = relative(this.config.rootPath, absPath);
|
|
68
90
|
if (shouldIgnore(relPath, this.config.ignorePatterns)) continue;
|
|
69
91
|
|
|
@@ -81,8 +103,24 @@ export class FileWatcher {
|
|
|
81
103
|
} catch {
|
|
82
104
|
// File may have been deleted between scan and hash
|
|
83
105
|
}
|
|
106
|
+
|
|
107
|
+
if ((i + 1) % 25 === 0 || i === entries.length - 1) {
|
|
108
|
+
opts?.onProgress?.({
|
|
109
|
+
phase: 'hashing',
|
|
110
|
+
current: i + 1,
|
|
111
|
+
total: entries.length,
|
|
112
|
+
message: `Hashed ${i + 1}/${entries.length} files`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
84
115
|
}
|
|
85
116
|
|
|
117
|
+
opts?.onProgress?.({
|
|
118
|
+
phase: 'done',
|
|
119
|
+
current: events.length,
|
|
120
|
+
total: events.length,
|
|
121
|
+
message: `Discovered ${events.length} trackable files`,
|
|
122
|
+
});
|
|
123
|
+
|
|
86
124
|
return events;
|
|
87
125
|
}
|
|
88
126
|
|