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.
@@ -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(): Promise<FileChangeEvent[]> {
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
- for (const absPath of entries) {
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