whygraph 0.1.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 +205 -0
- package/dist/cli/commands/config.d.ts +14 -0
- package/dist/cli/commands/config.js +123 -0
- package/dist/cli/commands/down.d.ts +9 -0
- package/dist/cli/commands/down.js +46 -0
- package/dist/cli/commands/init.d.ts +17 -0
- package/dist/cli/commands/init.js +144 -0
- package/dist/cli/commands/issues.d.ts +10 -0
- package/dist/cli/commands/issues.js +376 -0
- package/dist/cli/commands/mcp.d.ts +2 -0
- package/dist/cli/commands/mcp.js +9 -0
- package/dist/cli/commands/restart.d.ts +11 -0
- package/dist/cli/commands/restart.js +43 -0
- package/dist/cli/commands/serve.d.ts +14 -0
- package/dist/cli/commands/serve.js +132 -0
- package/dist/cli/commands/server-utils.d.ts +6 -0
- package/dist/cli/commands/server-utils.js +94 -0
- package/dist/cli/commands/status.d.ts +11 -0
- package/dist/cli/commands/status.js +97 -0
- package/dist/cli/commands/up.d.ts +13 -0
- package/dist/cli/commands/up.js +62 -0
- package/dist/cli/commands/validate.d.ts +14 -0
- package/dist/cli/commands/validate.js +88 -0
- package/dist/cli/commands/viz.d.ts +7 -0
- package/dist/cli/commands/viz.js +97 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +33 -0
- package/dist/entity/id.d.ts +8 -0
- package/dist/entity/id.js +48 -0
- package/dist/entity/issues.d.ts +12 -0
- package/dist/entity/issues.js +68 -0
- package/dist/entity/parser.d.ts +6 -0
- package/dist/entity/parser.js +166 -0
- package/dist/entity/types.d.ts +54 -0
- package/dist/entity/types.js +21 -0
- package/dist/entity/validate.d.ts +12 -0
- package/dist/entity/validate.js +136 -0
- package/dist/entity/writer.d.ts +16 -0
- package/dist/entity/writer.js +142 -0
- package/dist/frontend/assets/index-ByZzPwVe.css +1 -0
- package/dist/frontend/assets/index-F9dxfzD_.js +170 -0
- package/dist/frontend/index.html +14 -0
- package/dist/graph/cascade.d.ts +10 -0
- package/dist/graph/cascade.js +49 -0
- package/dist/graph/decisions.d.ts +11 -0
- package/dist/graph/decisions.js +27 -0
- package/dist/graph/gaps.d.ts +10 -0
- package/dist/graph/gaps.js +58 -0
- package/dist/graph/nodes.d.ts +20 -0
- package/dist/graph/nodes.js +33 -0
- package/dist/graph/projection.d.ts +6 -0
- package/dist/graph/projection.js +44 -0
- package/dist/graph/query.d.ts +15 -0
- package/dist/graph/query.js +82 -0
- package/dist/graph/search.d.ts +2 -0
- package/dist/graph/search.js +23 -0
- package/dist/graph/supersede.d.ts +7 -0
- package/dist/graph/supersede.js +48 -0
- package/dist/graph/temporal.d.ts +13 -0
- package/dist/graph/temporal.js +28 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.js +10 -0
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.js +340 -0
- package/dist/onboarding/interview.d.ts +22 -0
- package/dist/onboarding/interview.js +92 -0
- package/dist/onboarding/scan.d.ts +17 -0
- package/dist/onboarding/scan.js +106 -0
- package/dist/platform/rules.d.ts +8 -0
- package/dist/platform/rules.js +229 -0
- package/dist/server/core.d.ts +26 -0
- package/dist/server/core.js +111 -0
- package/dist/server/derived.d.ts +8 -0
- package/dist/server/derived.js +13 -0
- package/dist/server/etag.d.ts +9 -0
- package/dist/server/etag.js +25 -0
- package/dist/server/http.d.ts +13 -0
- package/dist/server/http.js +131 -0
- package/dist/server/pubsub.d.ts +12 -0
- package/dist/server/pubsub.js +19 -0
- package/dist/server/schema.d.ts +2 -0
- package/dist/server/schema.js +362 -0
- package/dist/server/stale-refs.d.ts +7 -0
- package/dist/server/stale-refs.js +23 -0
- package/dist/server/watcher.d.ts +21 -0
- package/dist/server/watcher.js +98 -0
- package/dist/server/worktree-watcher.d.ts +20 -0
- package/dist/server/worktree-watcher.js +79 -0
- package/dist/server/worktree.d.ts +22 -0
- package/dist/server/worktree.js +84 -0
- package/package.json +73 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { createSchema, createPubSub } from "graphql-yoga";
|
|
3
|
+
import { isStructuralNode, isDecisionNode } from "../entity/types.js";
|
|
4
|
+
import { generateId } from "../entity/id.js";
|
|
5
|
+
import { writeEntity } from "../entity/writer.js";
|
|
6
|
+
import { getContext } from "../graph/query.js";
|
|
7
|
+
import { getDecisions } from "../graph/decisions.js";
|
|
8
|
+
import { getGaps } from "../graph/gaps.js";
|
|
9
|
+
import { listNodes } from "../graph/nodes.js";
|
|
10
|
+
import { searchDecisions } from "../graph/search.js";
|
|
11
|
+
import { computeDerivedState } from "./derived.js";
|
|
12
|
+
const typeDefs = /* GraphQL */ `
|
|
13
|
+
type StructuralNode {
|
|
14
|
+
id: ID!
|
|
15
|
+
label: String!
|
|
16
|
+
name: String!
|
|
17
|
+
status: String!
|
|
18
|
+
parent: String
|
|
19
|
+
refs: [SymbolRef!]
|
|
20
|
+
description: String
|
|
21
|
+
created_at: String!
|
|
22
|
+
updated_at: String!
|
|
23
|
+
removed_at: String
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type DecisionNode {
|
|
27
|
+
id: ID!
|
|
28
|
+
label: String!
|
|
29
|
+
title: String!
|
|
30
|
+
status: String!
|
|
31
|
+
date: String!
|
|
32
|
+
affects: [String!]!
|
|
33
|
+
tags: [String!]!
|
|
34
|
+
supersedes: String
|
|
35
|
+
context: String!
|
|
36
|
+
decision: String!
|
|
37
|
+
tradeoffs: String!
|
|
38
|
+
alternatives: String!
|
|
39
|
+
created_at: String!
|
|
40
|
+
updated_at: String!
|
|
41
|
+
removed_at: String
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type SymbolRef {
|
|
45
|
+
file: String!
|
|
46
|
+
symbol: String
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
input SymbolRefInput {
|
|
50
|
+
file: String!
|
|
51
|
+
symbol: String
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
union Entity = StructuralNode | DecisionNode
|
|
55
|
+
|
|
56
|
+
type ContextNodeResult {
|
|
57
|
+
id: ID!
|
|
58
|
+
label: String!
|
|
59
|
+
name: String!
|
|
60
|
+
parentChain: [String!]!
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type ContextResult {
|
|
64
|
+
nodes: [ContextNodeResult!]!
|
|
65
|
+
decisions: [DecisionNode!]!
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type NodeSummary {
|
|
69
|
+
id: ID!
|
|
70
|
+
label: String!
|
|
71
|
+
name: String!
|
|
72
|
+
parent: String
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type ValidationErrorEntry {
|
|
76
|
+
field: String!
|
|
77
|
+
message: String!
|
|
78
|
+
severity: String!
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type EntityValidationError {
|
|
82
|
+
entityId: ID!
|
|
83
|
+
errors: [ValidationErrorEntry!]!
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type SupersedeCandidate {
|
|
87
|
+
newDecisionId: ID!
|
|
88
|
+
existingDecisionId: ID!
|
|
89
|
+
sharedNodeIds: [String!]!
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type Query {
|
|
93
|
+
entities: [Entity!]!
|
|
94
|
+
entity(id: ID!): Entity
|
|
95
|
+
status: ServerStatus!
|
|
96
|
+
context(file: String!, symbol: String): ContextResult!
|
|
97
|
+
decisions(status: String, tags: [String!], dateFrom: String, dateTo: String): [DecisionNode!]!
|
|
98
|
+
gaps(limit: Int): [StructuralNode!]!
|
|
99
|
+
nodes(label: String, parent: String, search: String): [NodeSummary!]!
|
|
100
|
+
search(query: String!): [DecisionNode!]!
|
|
101
|
+
validationErrors: [EntityValidationError!]!
|
|
102
|
+
supersedeCandidates: [SupersedeCandidate!]!
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
type ServerStatus {
|
|
106
|
+
running: Boolean!
|
|
107
|
+
entityCount: Int!
|
|
108
|
+
nodeCount: Int!
|
|
109
|
+
decisionCount: Int!
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
type Mutation {
|
|
113
|
+
ping: Boolean!
|
|
114
|
+
createNode(
|
|
115
|
+
label: String!
|
|
116
|
+
name: String!
|
|
117
|
+
parent: String
|
|
118
|
+
refs: [SymbolRefInput!]
|
|
119
|
+
description: String
|
|
120
|
+
): StructuralNode!
|
|
121
|
+
createDecision(
|
|
122
|
+
title: String!
|
|
123
|
+
date: String!
|
|
124
|
+
affects: [String!]!
|
|
125
|
+
tags: [String!]!
|
|
126
|
+
context: String!
|
|
127
|
+
decision: String!
|
|
128
|
+
tradeoffs: String!
|
|
129
|
+
alternatives: String!
|
|
130
|
+
supersedes: String
|
|
131
|
+
): DecisionNode!
|
|
132
|
+
updateEntity(
|
|
133
|
+
id: ID!
|
|
134
|
+
status: String
|
|
135
|
+
removed_at: String
|
|
136
|
+
refs: [SymbolRefInput!]
|
|
137
|
+
): Entity!
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
enum EntityChangeType {
|
|
141
|
+
CREATED
|
|
142
|
+
UPDATED
|
|
143
|
+
DELETED
|
|
144
|
+
INITIAL_SNAPSHOT
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
type EntityChangeEvent {
|
|
148
|
+
type: EntityChangeType!
|
|
149
|
+
entities: [Entity!]
|
|
150
|
+
entity: Entity
|
|
151
|
+
entityId: String
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
type Subscription {
|
|
155
|
+
entityChanged(includeInitial: Boolean): EntityChangeEvent!
|
|
156
|
+
}
|
|
157
|
+
`;
|
|
158
|
+
export function buildSchema(core) {
|
|
159
|
+
const yogaPubSub = createPubSub();
|
|
160
|
+
// Wire ServerCore's PubSub to Yoga's PubSub for subscriptions
|
|
161
|
+
core.pubsub.subscribe((event) => {
|
|
162
|
+
if (event.type === "entity_created") {
|
|
163
|
+
yogaPubSub.publish("entityChanged", {
|
|
164
|
+
type: "CREATED",
|
|
165
|
+
entity: event.entity,
|
|
166
|
+
entityId: event.entityId,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
else if (event.type === "entity_updated") {
|
|
170
|
+
yogaPubSub.publish("entityChanged", {
|
|
171
|
+
type: "UPDATED",
|
|
172
|
+
entity: event.entity,
|
|
173
|
+
entityId: event.entityId,
|
|
174
|
+
});
|
|
175
|
+
/* v8 ignore next 5 */
|
|
176
|
+
}
|
|
177
|
+
else if (event.type === "entity_deleted") {
|
|
178
|
+
yogaPubSub.publish("entityChanged", {
|
|
179
|
+
type: "DELETED",
|
|
180
|
+
entityId: event.entityId,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
return createSchema({
|
|
185
|
+
typeDefs,
|
|
186
|
+
resolvers: {
|
|
187
|
+
Entity: {
|
|
188
|
+
__resolveType(obj) {
|
|
189
|
+
if (isDecisionNode(obj))
|
|
190
|
+
return "DecisionNode";
|
|
191
|
+
return "StructuralNode";
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
Query: {
|
|
195
|
+
entities: () => core.getAllEntities(),
|
|
196
|
+
entity: (_, args) => core.getEntity(args.id) ?? null,
|
|
197
|
+
status: () => {
|
|
198
|
+
const all = core.getAllEntities();
|
|
199
|
+
const decisions = all.filter(isDecisionNode);
|
|
200
|
+
const nodes = all.filter(isStructuralNode);
|
|
201
|
+
return {
|
|
202
|
+
running: true,
|
|
203
|
+
entityCount: all.length,
|
|
204
|
+
nodeCount: nodes.length,
|
|
205
|
+
decisionCount: decisions.length,
|
|
206
|
+
};
|
|
207
|
+
},
|
|
208
|
+
context: (_, args) => {
|
|
209
|
+
const result = getContext(core.getGraph(), args.file, args.symbol ?? undefined);
|
|
210
|
+
return result;
|
|
211
|
+
},
|
|
212
|
+
decisions: (_, args) => {
|
|
213
|
+
const filters = {};
|
|
214
|
+
if (args.status !== undefined && args.status !== null) {
|
|
215
|
+
filters.status = args.status;
|
|
216
|
+
}
|
|
217
|
+
if (args.tags !== undefined && args.tags !== null) {
|
|
218
|
+
filters.tags = args.tags;
|
|
219
|
+
}
|
|
220
|
+
if (args.dateFrom !== undefined && args.dateFrom !== null) {
|
|
221
|
+
filters.dateFrom = args.dateFrom;
|
|
222
|
+
}
|
|
223
|
+
if (args.dateTo !== undefined && args.dateTo !== null) {
|
|
224
|
+
filters.dateTo = args.dateTo;
|
|
225
|
+
}
|
|
226
|
+
return getDecisions(core.getGraph(), filters);
|
|
227
|
+
},
|
|
228
|
+
gaps: (_, args) => {
|
|
229
|
+
return getGaps(core.getGraph(), args.limit ?? undefined);
|
|
230
|
+
},
|
|
231
|
+
nodes: (_, args) => {
|
|
232
|
+
const filters = {};
|
|
233
|
+
if (args.label !== undefined && args.label !== null) {
|
|
234
|
+
filters.label = args.label;
|
|
235
|
+
}
|
|
236
|
+
if (args.parent !== undefined && args.parent !== null) {
|
|
237
|
+
filters.parent = args.parent;
|
|
238
|
+
}
|
|
239
|
+
if (args.search !== undefined && args.search !== null) {
|
|
240
|
+
filters.search = args.search;
|
|
241
|
+
}
|
|
242
|
+
return listNodes(core.getGraph(), filters);
|
|
243
|
+
},
|
|
244
|
+
search: (_, args) => {
|
|
245
|
+
return searchDecisions(core.getEntityMap(), args.query);
|
|
246
|
+
},
|
|
247
|
+
validationErrors: () => {
|
|
248
|
+
return core.getIssues().map((issue) => ({
|
|
249
|
+
entityId: issue.entityId,
|
|
250
|
+
errors: issue.errors,
|
|
251
|
+
}));
|
|
252
|
+
},
|
|
253
|
+
supersedeCandidates: () => {
|
|
254
|
+
const derived = computeDerivedState(core.getEntityMap());
|
|
255
|
+
return derived.supersedeCandidates;
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
Mutation: {
|
|
259
|
+
ping: () => true,
|
|
260
|
+
createNode: (_, args) => {
|
|
261
|
+
const now = new Date().toISOString();
|
|
262
|
+
const id = generateId();
|
|
263
|
+
const graphDir = path.join(core.getWhygraphDir(), "graph");
|
|
264
|
+
const node = {
|
|
265
|
+
id,
|
|
266
|
+
label: args.label,
|
|
267
|
+
name: args.name,
|
|
268
|
+
status: "active",
|
|
269
|
+
created_at: now,
|
|
270
|
+
updated_at: now,
|
|
271
|
+
};
|
|
272
|
+
if (args.parent !== undefined)
|
|
273
|
+
node.parent = args.parent;
|
|
274
|
+
if (args.refs !== undefined)
|
|
275
|
+
node.refs = args.refs;
|
|
276
|
+
if (args.description !== undefined)
|
|
277
|
+
node.description = args.description;
|
|
278
|
+
writeEntity(graphDir, node);
|
|
279
|
+
core.addOrUpdateEntity(id, node);
|
|
280
|
+
return node;
|
|
281
|
+
},
|
|
282
|
+
createDecision: (_, args) => {
|
|
283
|
+
const now = new Date().toISOString();
|
|
284
|
+
const id = generateId();
|
|
285
|
+
const graphDir = path.join(core.getWhygraphDir(), "graph");
|
|
286
|
+
const decision = {
|
|
287
|
+
id,
|
|
288
|
+
label: "Decision",
|
|
289
|
+
title: args.title,
|
|
290
|
+
status: "active",
|
|
291
|
+
date: args.date,
|
|
292
|
+
affects: args.affects,
|
|
293
|
+
tags: args.tags,
|
|
294
|
+
context: args.context,
|
|
295
|
+
decision: args.decision,
|
|
296
|
+
tradeoffs: args.tradeoffs,
|
|
297
|
+
alternatives: args.alternatives,
|
|
298
|
+
created_at: now,
|
|
299
|
+
updated_at: now,
|
|
300
|
+
};
|
|
301
|
+
if (args.supersedes !== undefined)
|
|
302
|
+
decision.supersedes = args.supersedes;
|
|
303
|
+
writeEntity(graphDir, decision);
|
|
304
|
+
core.addOrUpdateEntity(id, decision);
|
|
305
|
+
return decision;
|
|
306
|
+
},
|
|
307
|
+
updateEntity: (_, args) => {
|
|
308
|
+
const existing = core.getEntity(args.id);
|
|
309
|
+
if (!existing) {
|
|
310
|
+
throw new Error(`Entity not found: ${args.id}`);
|
|
311
|
+
}
|
|
312
|
+
const now = new Date().toISOString();
|
|
313
|
+
const graphDir = path.join(core.getWhygraphDir(), "graph");
|
|
314
|
+
const updated = { ...existing, updated_at: now };
|
|
315
|
+
if (args.status !== undefined) {
|
|
316
|
+
updated.status = args.status;
|
|
317
|
+
}
|
|
318
|
+
if (args.removed_at !== undefined) {
|
|
319
|
+
updated.removed_at = args.removed_at;
|
|
320
|
+
}
|
|
321
|
+
if (args.refs !== undefined && isStructuralNode(updated)) {
|
|
322
|
+
updated.refs = args.refs;
|
|
323
|
+
}
|
|
324
|
+
writeEntity(graphDir, updated);
|
|
325
|
+
core.addOrUpdateEntity(args.id, updated);
|
|
326
|
+
return updated;
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
Subscription: {
|
|
330
|
+
entityChanged: {
|
|
331
|
+
subscribe: (_, args) => {
|
|
332
|
+
const baseIterator = yogaPubSub.subscribe("entityChanged");
|
|
333
|
+
if (!args.includeInitial) {
|
|
334
|
+
return baseIterator;
|
|
335
|
+
}
|
|
336
|
+
// Wrap with initial snapshot
|
|
337
|
+
async function* withInitialSnapshot() {
|
|
338
|
+
const allEntities = core.getAllEntities();
|
|
339
|
+
yield {
|
|
340
|
+
entityChanged: {
|
|
341
|
+
type: "INITIAL_SNAPSHOT",
|
|
342
|
+
entities: allEntities,
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
for await (const event of baseIterator) {
|
|
346
|
+
yield event;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return withInitialSnapshot();
|
|
350
|
+
},
|
|
351
|
+
resolve: (event) => {
|
|
352
|
+
// Events from yogaPubSub come wrapped, initial snapshot comes unwrapped
|
|
353
|
+
if ("entityChanged" in event) {
|
|
354
|
+
return event.entityChanged;
|
|
355
|
+
}
|
|
356
|
+
return event;
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { isStructuralNode } from "../entity/types.js";
|
|
4
|
+
export function detectStaleRefs(entities, basePath) {
|
|
5
|
+
const staleRefs = [];
|
|
6
|
+
for (const [id, entity] of entities) {
|
|
7
|
+
if (!isStructuralNode(entity))
|
|
8
|
+
continue;
|
|
9
|
+
if (!entity.refs || entity.refs.length === 0)
|
|
10
|
+
continue;
|
|
11
|
+
for (const ref of entity.refs) {
|
|
12
|
+
const absolutePath = path.resolve(basePath, ref.file);
|
|
13
|
+
if (!fs.existsSync(absolutePath)) {
|
|
14
|
+
staleRefs.push({
|
|
15
|
+
entityId: id,
|
|
16
|
+
entityName: entity.name,
|
|
17
|
+
ref,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return staleRefs;
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface WatcherEvent {
|
|
2
|
+
type: "created" | "updated" | "deleted";
|
|
3
|
+
filePath: string;
|
|
4
|
+
entityId?: string;
|
|
5
|
+
}
|
|
6
|
+
export type WatcherCallback = (events: WatcherEvent[]) => void;
|
|
7
|
+
export declare class FileWatcher {
|
|
8
|
+
private readonly dirPath;
|
|
9
|
+
private watcher;
|
|
10
|
+
private pendingEvents;
|
|
11
|
+
private debounceTimer;
|
|
12
|
+
private callback;
|
|
13
|
+
private knownFiles;
|
|
14
|
+
constructor(dirPath: string);
|
|
15
|
+
start(callback: WatcherCallback): void;
|
|
16
|
+
stop(): Promise<void>;
|
|
17
|
+
private handleFileChange;
|
|
18
|
+
private enqueue;
|
|
19
|
+
private scheduleBatch;
|
|
20
|
+
private flush;
|
|
21
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import { watch } from "chokidar";
|
|
3
|
+
import { parseEntity } from "../entity/parser.js";
|
|
4
|
+
import { parseFilename } from "../entity/id.js";
|
|
5
|
+
export class FileWatcher {
|
|
6
|
+
dirPath;
|
|
7
|
+
watcher = null;
|
|
8
|
+
pendingEvents = [];
|
|
9
|
+
debounceTimer = null;
|
|
10
|
+
callback = null;
|
|
11
|
+
knownFiles = new Set();
|
|
12
|
+
constructor(dirPath) {
|
|
13
|
+
this.dirPath = dirPath;
|
|
14
|
+
}
|
|
15
|
+
start(callback) {
|
|
16
|
+
this.callback = callback;
|
|
17
|
+
this.watcher = watch(this.dirPath, {
|
|
18
|
+
ignoreInitial: true,
|
|
19
|
+
awaitWriteFinish: false,
|
|
20
|
+
});
|
|
21
|
+
this.watcher.on("add", (filePath) => {
|
|
22
|
+
if (!filePath.endsWith(".md"))
|
|
23
|
+
return;
|
|
24
|
+
this.knownFiles.add(filePath);
|
|
25
|
+
this.handleFileChange(filePath, "created");
|
|
26
|
+
});
|
|
27
|
+
this.watcher.on("change", (filePath) => {
|
|
28
|
+
/* v8 ignore next 1 */
|
|
29
|
+
if (!filePath.endsWith(".md"))
|
|
30
|
+
return;
|
|
31
|
+
this.handleFileChange(filePath, "updated");
|
|
32
|
+
});
|
|
33
|
+
this.watcher.on("unlink", (filePath) => {
|
|
34
|
+
/* v8 ignore next 1 */
|
|
35
|
+
if (!filePath.endsWith(".md"))
|
|
36
|
+
return;
|
|
37
|
+
this.knownFiles.delete(filePath);
|
|
38
|
+
/* v8 ignore next 1 */
|
|
39
|
+
const entityId = parseFilename(filePath) ?? undefined;
|
|
40
|
+
this.enqueue({
|
|
41
|
+
type: "deleted",
|
|
42
|
+
filePath,
|
|
43
|
+
entityId,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
async stop() {
|
|
48
|
+
/* v8 ignore next 4 */
|
|
49
|
+
if (this.debounceTimer) {
|
|
50
|
+
clearTimeout(this.debounceTimer);
|
|
51
|
+
this.debounceTimer = null;
|
|
52
|
+
}
|
|
53
|
+
if (this.watcher) {
|
|
54
|
+
await this.watcher.close();
|
|
55
|
+
this.watcher = null;
|
|
56
|
+
}
|
|
57
|
+
this.pendingEvents = [];
|
|
58
|
+
this.callback = null;
|
|
59
|
+
this.knownFiles.clear();
|
|
60
|
+
}
|
|
61
|
+
handleFileChange(filePath, type) {
|
|
62
|
+
fs.readFile(filePath, "utf-8")
|
|
63
|
+
.then((content) => {
|
|
64
|
+
const entity = parseEntity(content);
|
|
65
|
+
this.enqueue({
|
|
66
|
+
type,
|
|
67
|
+
filePath,
|
|
68
|
+
entityId: entity?.id,
|
|
69
|
+
});
|
|
70
|
+
})
|
|
71
|
+
/* v8 ignore start */
|
|
72
|
+
.catch(() => {
|
|
73
|
+
// File may have been deleted between event and read — ignore
|
|
74
|
+
});
|
|
75
|
+
/* v8 ignore stop */
|
|
76
|
+
}
|
|
77
|
+
enqueue(event) {
|
|
78
|
+
this.pendingEvents.push(event);
|
|
79
|
+
this.scheduleBatch();
|
|
80
|
+
}
|
|
81
|
+
scheduleBatch() {
|
|
82
|
+
if (this.debounceTimer) {
|
|
83
|
+
clearTimeout(this.debounceTimer);
|
|
84
|
+
}
|
|
85
|
+
this.debounceTimer = setTimeout(() => {
|
|
86
|
+
this.flush();
|
|
87
|
+
}, 100);
|
|
88
|
+
}
|
|
89
|
+
flush() {
|
|
90
|
+
/* v8 ignore next 1 */
|
|
91
|
+
if (this.pendingEvents.length === 0 || !this.callback)
|
|
92
|
+
return;
|
|
93
|
+
const events = this.pendingEvents;
|
|
94
|
+
this.pendingEvents = [];
|
|
95
|
+
this.debounceTimer = null;
|
|
96
|
+
this.callback(events);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ServerCore } from "./core.js";
|
|
2
|
+
import { type DetectDeps, type WorktreeInfo } from "./worktree.js";
|
|
3
|
+
export interface WorktreeWatcherDeps {
|
|
4
|
+
detectWorktrees: (repoDir: string, deps?: DetectDeps) => Promise<WorktreeInfo[]>;
|
|
5
|
+
}
|
|
6
|
+
export declare class WorktreeWatcher {
|
|
7
|
+
private readonly core;
|
|
8
|
+
private readonly repoDir;
|
|
9
|
+
private readonly watchers;
|
|
10
|
+
private readonly worktreeEntities;
|
|
11
|
+
private readonly dirtyEntities;
|
|
12
|
+
private readonly deps;
|
|
13
|
+
constructor(core: ServerCore, repoDir: string, deps?: WorktreeWatcherDeps);
|
|
14
|
+
startWatching(): Promise<void>;
|
|
15
|
+
stop(): Promise<void>;
|
|
16
|
+
getWatchedWorktrees(): string[];
|
|
17
|
+
isWorktreeEntity(id: string): boolean;
|
|
18
|
+
getDirtyIds(): string[];
|
|
19
|
+
private handleEvents;
|
|
20
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import { FileWatcher } from "./watcher.js";
|
|
3
|
+
import { detectWorktrees } from "./worktree.js";
|
|
4
|
+
import { parseEntity } from "../entity/parser.js";
|
|
5
|
+
import { computeETag } from "./etag.js";
|
|
6
|
+
export class WorktreeWatcher {
|
|
7
|
+
core;
|
|
8
|
+
repoDir;
|
|
9
|
+
watchers = new Map();
|
|
10
|
+
worktreeEntities = new Set();
|
|
11
|
+
dirtyEntities = new Set();
|
|
12
|
+
deps;
|
|
13
|
+
constructor(core, repoDir, deps) {
|
|
14
|
+
this.core = core;
|
|
15
|
+
this.repoDir = repoDir;
|
|
16
|
+
this.deps = deps ?? { detectWorktrees };
|
|
17
|
+
}
|
|
18
|
+
async startWatching() {
|
|
19
|
+
const worktrees = await this.deps.detectWorktrees(this.repoDir);
|
|
20
|
+
for (const wt of worktrees) {
|
|
21
|
+
const watcher = new FileWatcher(wt.graphDir);
|
|
22
|
+
this.watchers.set(wt.path, watcher);
|
|
23
|
+
watcher.start((events) => {
|
|
24
|
+
this.handleEvents(events, wt);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async stop() {
|
|
29
|
+
const stops = Array.from(this.watchers.values()).map((w) => w.stop());
|
|
30
|
+
await Promise.all(stops);
|
|
31
|
+
this.watchers.clear();
|
|
32
|
+
}
|
|
33
|
+
getWatchedWorktrees() {
|
|
34
|
+
return Array.from(this.watchers.keys());
|
|
35
|
+
}
|
|
36
|
+
isWorktreeEntity(id) {
|
|
37
|
+
return this.worktreeEntities.has(id);
|
|
38
|
+
}
|
|
39
|
+
getDirtyIds() {
|
|
40
|
+
return Array.from(this.dirtyEntities);
|
|
41
|
+
}
|
|
42
|
+
handleEvents(events, wt) {
|
|
43
|
+
for (const event of events) {
|
|
44
|
+
if (event.type === "deleted" && event.entityId) {
|
|
45
|
+
this.worktreeEntities.delete(event.entityId);
|
|
46
|
+
this.dirtyEntities.delete(event.entityId);
|
|
47
|
+
this.core.removeEntity(event.entityId);
|
|
48
|
+
/* v8 ignore next 1 */
|
|
49
|
+
}
|
|
50
|
+
else if (event.entityId) {
|
|
51
|
+
this.worktreeEntities.add(event.entityId);
|
|
52
|
+
// Read the file to get the full entity
|
|
53
|
+
fs.readFile(event.filePath, "utf-8")
|
|
54
|
+
.then((content) => {
|
|
55
|
+
const entity = parseEntity(content);
|
|
56
|
+
/* v8 ignore next 1 */
|
|
57
|
+
if (entity) {
|
|
58
|
+
// Track dirty state via ETag comparison with main entity
|
|
59
|
+
const mainEntity = this.core.getEntity(entity.id);
|
|
60
|
+
if (mainEntity && computeETag(entity) === computeETag(mainEntity)) {
|
|
61
|
+
this.dirtyEntities.delete(entity.id);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
this.dirtyEntities.add(entity.id);
|
|
65
|
+
}
|
|
66
|
+
// Mark entity as coming from a worktree
|
|
67
|
+
const tagged = { ...entity, _worktree: wt.path };
|
|
68
|
+
this.core.addOrUpdateEntity(entity.id, tagged);
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
/* v8 ignore start */
|
|
72
|
+
.catch(() => {
|
|
73
|
+
// File may have been deleted between event and read
|
|
74
|
+
});
|
|
75
|
+
/* v8 ignore stop */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface WorktreeInfo {
|
|
2
|
+
path: string;
|
|
3
|
+
branch: string;
|
|
4
|
+
graphDir: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ParsedWorktree {
|
|
7
|
+
path: string;
|
|
8
|
+
branch: string | null;
|
|
9
|
+
isMain: boolean;
|
|
10
|
+
}
|
|
11
|
+
/** Dependency injection for testing. */
|
|
12
|
+
export interface DetectDeps {
|
|
13
|
+
execFile: (cmd: string, args: string[], opts: {
|
|
14
|
+
cwd: string;
|
|
15
|
+
}) => Promise<{
|
|
16
|
+
stdout: string;
|
|
17
|
+
stderr: string;
|
|
18
|
+
}>;
|
|
19
|
+
access: (p: string) => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
export declare function parseWorktreeList(output: string): ParsedWorktree[];
|
|
22
|
+
export declare function detectWorktrees(repoDir: string, deps?: DetectDeps): Promise<WorktreeInfo[]>;
|