pi-weave 0.1.7 → 0.1.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/README.md +114 -34
- package/package.json +16 -4
- package/src/core/cache/workspace.ts +466 -0
- package/src/core/frontmatter.ts +217 -23
- package/src/core/git.ts +19 -0
- package/src/core/graph/build.ts +37 -4
- package/src/core/graph/current.ts +41 -28
- package/src/core/graph/mentions.ts +170 -0
- package/src/core/graph/model.ts +24 -0
- package/src/core/index.ts +12 -0
- package/src/core/openInEditor.ts +69 -0
- package/src/core/types.ts +40 -0
- package/src/core/vault.ts +477 -43
- package/src/core/view/cluster.ts +262 -0
- package/src/core/view/detail.ts +118 -0
- package/src/core/view/focus.ts +109 -0
- package/src/core/view/health.ts +156 -0
- package/src/core/view/index.ts +15 -0
- package/src/core/view/links.ts +105 -0
- package/src/core/view/time.ts +47 -0
- package/src/core/view/tree.ts +269 -0
- package/src/core/view/types.ts +39 -0
- package/src/pi/index.ts +104 -11
- package/src/pi/viewer/tui/explorer.ts +4 -2
- package/src/pi/viewer/tui/model.ts +46 -667
- package/src/pi/viewer/tui/openNote.ts +7 -56
- package/src/pi/viewer/tui/surface/explore.ts +4 -2
- package/src/pi/viewer/web/run.ts +331 -0
- package/src/web/client/api.dom.ts +40 -0
- package/src/web/client/api.ts +472 -0
- package/src/web/client/bootstrap.ts +58 -0
- package/src/web/client/context/context.model.ts +313 -0
- package/src/web/client/dist/app.js +751 -0
- package/src/web/client/graph/Graph.tsx +158 -0
- package/src/web/client/graph/column.model.ts +431 -0
- package/src/web/client/graph/graph.model.ts +538 -0
- package/src/web/client/graph/positions.ts +339 -0
- package/src/web/client/graph/project.ts +153 -0
- package/src/web/client/graph/renderer.dom.ts +52 -0
- package/src/web/client/graph/renderer.ts +279 -0
- package/src/web/client/graph/scheme.ts +44 -0
- package/src/web/client/live.model.ts +275 -0
- package/src/web/client/live.ts +151 -0
- package/src/web/client/main.tsx +27 -0
- package/src/web/client/note/Editor.tsx +102 -0
- package/src/web/client/note/Note.tsx +113 -0
- package/src/web/client/note/editor.controller.ts +151 -0
- package/src/web/client/note/editor.model.ts +636 -0
- package/src/web/client/note/note.model.ts +738 -0
- package/src/web/client/search/SearchPalette.tsx +105 -0
- package/src/web/client/search/search.model.ts +588 -0
- package/src/web/client/search/search.ts +107 -0
- package/src/web/client/shell/Columns.tsx +161 -0
- package/src/web/client/shell/ContextRail.tsx +87 -0
- package/src/web/client/shell/Divider.tsx +44 -0
- package/src/web/client/shell/FocusTrap.tsx +56 -0
- package/src/web/client/shell/Header.tsx +54 -0
- package/src/web/client/shell/HelpOverlay.tsx +70 -0
- package/src/web/client/shell/Shell.tsx +193 -0
- package/src/web/client/shell/StatusBar.tsx +28 -0
- package/src/web/client/shell/cssvars.ts +70 -0
- package/src/web/client/shell/drag.model.ts +170 -0
- package/src/web/client/shell/focus.model.ts +100 -0
- package/src/web/client/shell/keys.model.ts +453 -0
- package/src/web/client/shell/keys.ts +59 -0
- package/src/web/client/shell/layout.model.ts +526 -0
- package/src/web/client/shell/shell.model.ts +333 -0
- package/src/web/client/shell/theme.ts +477 -0
- package/src/web/client/shell/viewport.ts +29 -0
- package/src/web/client/state.ts +78 -0
- package/src/web/client/tree/Tree.tsx +138 -0
- package/src/web/client/tree/tree.model.ts +674 -0
- package/src/web/client/workspace.ts +214 -0
- package/src/web/server/page.ts +256 -0
- package/src/web/server/routes.ts +975 -0
- package/src/web/server/security.ts +361 -0
- package/src/web/server/server.ts +275 -0
- package/src/web/server/sse.ts +321 -0
- package/src/web/server/watcher.ts +507 -0
- package/src/web/shared/graph.ts +206 -0
- package/src/web/shared/layout.ts +497 -0
- package/src/web/shared/metrics.ts +136 -0
- package/src/web/shared/view.ts +200 -0
- package/src/web/shared/wire.ts +358 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkspaceCache — an mtime-keyed cache over `buildCurrentGraph`
|
|
3
|
+
* (weave-workspace §4.1).
|
|
4
|
+
*
|
|
5
|
+
* The browser workspace pushes a graph over SSE on every file event, so the
|
|
6
|
+
* uncached path — N note reads plus ~5 git spawns per graph — becomes the
|
|
7
|
+
* bottleneck immediately. This layer keeps the parsed notes and the
|
|
8
|
+
* staleness report between builds and re-reads only what actually changed:
|
|
9
|
+
*
|
|
10
|
+
* - notes: one `stat` per file per build; a note is re-read only when its
|
|
11
|
+
* `mtimeMs` or `size` moved. A no-change rebuild costs N stats, zero
|
|
12
|
+
* reads, and zero git spawns — the headline target of §4.1.
|
|
13
|
+
* - repository side (index + staleness + summaries): held behind a short
|
|
14
|
+
* TTL because assessing staleness spawns git and sha1s every dirty file.
|
|
15
|
+
*
|
|
16
|
+
* Correctness contract: for the same on-disk inputs, `graph()` returns a
|
|
17
|
+
* model deep-equal to `buildCurrentGraph(cwd, vaultRoot)`. The cache is an
|
|
18
|
+
* optimisation, never a different answer — `tests/core/cache/workspace.test`
|
|
19
|
+
* asserts that equivalence directly.
|
|
20
|
+
*
|
|
21
|
+
* Stale-read hazard: mtime has coarse resolution on some filesystems, so a
|
|
22
|
+
* write landing in the same millisecond as the previous one with an
|
|
23
|
+
* identical size would be missed. The watcher closes that gap by calling
|
|
24
|
+
* `invalidate(path)`, which drops the entry unconditionally; mtime+size is
|
|
25
|
+
* the fallback for changes that arrive without an event (or before the
|
|
26
|
+
* watcher starts).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
30
|
+
import { gitSpawnCount } from "../git";
|
|
31
|
+
import { NOTES_DIR } from "../paths";
|
|
32
|
+
import { buildGraph, DEFAULT_MAX_NOTES, type BuildGraphInput } from "../graph/build";
|
|
33
|
+
import type { GraphModel } from "../graph/model";
|
|
34
|
+
import { readRepositorySide } from "../graph/current";
|
|
35
|
+
import { withMutationQueue } from "../mutex";
|
|
36
|
+
import { getNote, statNotes } from "../vault";
|
|
37
|
+
import type { Note } from "../types";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One build's outputs: the graph, and the notes it was built from.
|
|
41
|
+
*
|
|
42
|
+
* The two travel together because a caller deriving anything per-note — the
|
|
43
|
+
* tag index (§4.3) is the motivating case — must use *the same* list the
|
|
44
|
+
* graph used, including the `DEFAULT_MAX_NOTES` truncation. Reading the notes
|
|
45
|
+
* from a second call would let the cap fall between them and produce a tag
|
|
46
|
+
* pointing at a slug the graph has no node for.
|
|
47
|
+
*/
|
|
48
|
+
export interface WorkspaceSnapshot {
|
|
49
|
+
model: GraphModel;
|
|
50
|
+
/**
|
|
51
|
+
* Exactly the notes `buildGraph` saw: newest-updated first and already
|
|
52
|
+
* truncated to the cap. Frozen — this is the cache's own array and a
|
|
53
|
+
* caller mutating it would corrupt the next build.
|
|
54
|
+
*/
|
|
55
|
+
notes: readonly Note[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Cumulative counters, from construction. Callers take deltas. */
|
|
59
|
+
export interface CacheStats {
|
|
60
|
+
/** Note files actually read and parsed. */
|
|
61
|
+
notesRead: number;
|
|
62
|
+
/** Note reads avoided because mtime+size were unchanged. */
|
|
63
|
+
notesCached: number;
|
|
64
|
+
/** git subprocesses spawned by builds this cache performed. */
|
|
65
|
+
gitCalls: number;
|
|
66
|
+
/** ISO timestamp of the last completed build; empty before the first. */
|
|
67
|
+
builtAt: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What a cached note costs to validate: its change stamp plus the parse. */
|
|
71
|
+
interface CachedNote {
|
|
72
|
+
mtimeMs: number;
|
|
73
|
+
size: number;
|
|
74
|
+
note: Note;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The repository half, held behind a TTL because assessing it spawns git. */
|
|
78
|
+
interface CachedRepo {
|
|
79
|
+
/** Wall-clock ms (from the injected clock) when this was captured. */
|
|
80
|
+
at: number;
|
|
81
|
+
value: Pick<BuildGraphInput, "repository" | "summaries"> | null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* How long a staleness assessment is trusted. Short enough that a `git
|
|
86
|
+
* commit` in another terminal shows up promptly, long enough that a burst of
|
|
87
|
+
* SSE-triggered rebuilds costs one assessment rather than one each.
|
|
88
|
+
*/
|
|
89
|
+
export const DEFAULT_STALENESS_TTL_MS = 2_000;
|
|
90
|
+
|
|
91
|
+
export interface WorkspaceCacheOptions {
|
|
92
|
+
cwd: string;
|
|
93
|
+
vaultRoot: string;
|
|
94
|
+
/** Injected clock (project convention: never read the wall clock directly). */
|
|
95
|
+
now?: () => Date;
|
|
96
|
+
/** Staleness TTL in ms. 0 disables caching of the repository side. */
|
|
97
|
+
stalenessTtlMs?: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Which half of the workspace a changed path belongs to. */
|
|
101
|
+
export type InvalidationScope = "vault" | "repo" | "none";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Classify an absolute path into the cache scope it invalidates.
|
|
105
|
+
*
|
|
106
|
+
* Exported because the watcher wants the same classification to decide
|
|
107
|
+
* whether an event is worth forwarding at all, and one implementation means
|
|
108
|
+
* the two can never disagree.
|
|
109
|
+
*
|
|
110
|
+
* - `vault`: a `*.md` under `<vaultRoot>/notes/`. Only note files count —
|
|
111
|
+
* the vault manifest does not participate in the graph.
|
|
112
|
+
* - `repo`: anything under `<cwd>/.okf/` (the derived index and its summary
|
|
113
|
+
* sidecars) or under `<cwd>/.git/` (HEAD moves, staged changes), plus any
|
|
114
|
+
* tracked file in the repo, since editing one makes the index stale.
|
|
115
|
+
* - `none`: outside both trees.
|
|
116
|
+
*
|
|
117
|
+
* A vault that lives *inside* the repo is deliberately resolved vault-first:
|
|
118
|
+
* a note write should not force a git re-assessment.
|
|
119
|
+
*/
|
|
120
|
+
export function classifyPath(
|
|
121
|
+
absPath: string,
|
|
122
|
+
opts: { cwd: string; vaultRoot: string },
|
|
123
|
+
): InvalidationScope {
|
|
124
|
+
return classify(absPath, opts).scope;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The classification plus, for a vault note, the slug it identifies —
|
|
129
|
+
* `invalidate` needs both and deriving them separately would mean resolving
|
|
130
|
+
* the same path twice and re-deriving a guard the classification already
|
|
131
|
+
* proved.
|
|
132
|
+
*/
|
|
133
|
+
function classify(
|
|
134
|
+
absPath: string,
|
|
135
|
+
opts: { cwd: string; vaultRoot: string },
|
|
136
|
+
): { scope: InvalidationScope; slug: string | null } {
|
|
137
|
+
const path = resolve(absPath);
|
|
138
|
+
const rel = relative(resolve(opts.vaultRoot, NOTES_DIR), path);
|
|
139
|
+
// Directly inside the notes dir (flat vault: no separator in the relative
|
|
140
|
+
// path) and Markdown — anything else in there is not a note.
|
|
141
|
+
if (rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel) && !rel.includes(sep)) {
|
|
142
|
+
if (rel.endsWith(".md")) return { scope: "vault", slug: rel.slice(0, -".md".length) };
|
|
143
|
+
return { scope: "none", slug: null };
|
|
144
|
+
}
|
|
145
|
+
return { scope: within(path, resolve(opts.cwd)) ? "repo" : "none", slug: null };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** True when `path` is `root` itself or sits underneath it. */
|
|
149
|
+
function within(path: string, root: string): boolean {
|
|
150
|
+
if (path === root) return true;
|
|
151
|
+
const rel = relative(root, path);
|
|
152
|
+
return rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A cached view of one workspace.
|
|
157
|
+
*
|
|
158
|
+
* A class rather than the plain async functions used elsewhere in core: this
|
|
159
|
+
* is the first thing in the tree with a lifetime and mutable identity. The
|
|
160
|
+
* watcher holds a reference and calls `invalidate` between reads, so the
|
|
161
|
+
* state *is* the object — a factory returning closures would be the same
|
|
162
|
+
* design wearing a different hat, with worse stack traces. Every method
|
|
163
|
+
* stays a plain async function over injected inputs, so it is as testable as
|
|
164
|
+
* the rest of core.
|
|
165
|
+
*
|
|
166
|
+
* Not safe to share across different `cwd`/`vaultRoot` pairs — construct one
|
|
167
|
+
* per workspace.
|
|
168
|
+
*/
|
|
169
|
+
export class WorkspaceCache {
|
|
170
|
+
private readonly cwd: string;
|
|
171
|
+
private readonly vaultRoot: string;
|
|
172
|
+
private readonly now: () => Date;
|
|
173
|
+
private readonly stalenessTtlMs: number;
|
|
174
|
+
|
|
175
|
+
private notes = new Map<string, CachedNote>();
|
|
176
|
+
/**
|
|
177
|
+
* `.md` files present at the last refresh, including ones too malformed to
|
|
178
|
+
* parse — mirrors `readVault().fileCount` so the vault node's note count
|
|
179
|
+
* matches the uncached build exactly.
|
|
180
|
+
*/
|
|
181
|
+
private fileCount = 0;
|
|
182
|
+
private repo: CachedRepo | null = null;
|
|
183
|
+
/**
|
|
184
|
+
* The last snapshot handed out, reused verbatim when a build proves nothing
|
|
185
|
+
* moved.
|
|
186
|
+
*
|
|
187
|
+
* `buildGraph` is pure and byte-deterministic, so rebuilding unchanged
|
|
188
|
+
* inputs allocates a fresh object that is deep-equal to this one and
|
|
189
|
+
* nothing else. Returning the *identical* object instead buys two things:
|
|
190
|
+
* the graph construction itself is skipped on the warm path (it was the
|
|
191
|
+
* only remaining per-request cost once note reads and git spawns were
|
|
192
|
+
* eliminated), and downstream consumers gain a stable identity they can
|
|
193
|
+
* memoize against — `src/web/server/routes` keys its serialized-payload
|
|
194
|
+
* and ETag cache off exactly this reference.
|
|
195
|
+
*
|
|
196
|
+
* Reuse is deliberately conservative: see {@link build} for the four
|
|
197
|
+
* conditions, all of which must hold.
|
|
198
|
+
*/
|
|
199
|
+
private lastSnapshot: WorkspaceSnapshot | null = null;
|
|
200
|
+
/** The single in-flight build, so concurrent callers coalesce. */
|
|
201
|
+
private inFlight: Promise<WorkspaceSnapshot> | null = null;
|
|
202
|
+
/**
|
|
203
|
+
* The `.model` projection of {@link inFlight}, memoized.
|
|
204
|
+
*
|
|
205
|
+
* Derived once rather than per call so `graph()` keeps its documented
|
|
206
|
+
* contract of handing *the identical promise* to concurrent callers. A
|
|
207
|
+
* bare `async graph()` would allocate a fresh promise each time — the same
|
|
208
|
+
* build, but no longer the same object, which is a coalescing guarantee
|
|
209
|
+
* the tests pin directly.
|
|
210
|
+
*/
|
|
211
|
+
private inFlightGraph: Promise<GraphModel> | null = null;
|
|
212
|
+
/**
|
|
213
|
+
* Slugs invalidated while a build was in flight.
|
|
214
|
+
*
|
|
215
|
+
* A build ends by replacing the note map wholesale, which would otherwise
|
|
216
|
+
* resurrect an entry the watcher dropped mid-flight and leave the cache
|
|
217
|
+
* serving a stale note indefinitely. Collecting them here and applying the
|
|
218
|
+
* eviction after the swap means the change is picked up by the *next*
|
|
219
|
+
* build instead of being lost. Same reasoning for `repoDirtiedDuringBuild`.
|
|
220
|
+
*/
|
|
221
|
+
private evictedDuringBuild = new Set<string>();
|
|
222
|
+
private allEvictedDuringBuild = false;
|
|
223
|
+
private repoDirtiedDuringBuild = false;
|
|
224
|
+
private building = false;
|
|
225
|
+
/**
|
|
226
|
+
* Whether the last {@link refreshNotes} observed any note-side movement:
|
|
227
|
+
* a file read, a note that disappeared, or a change in the raw `.md` count.
|
|
228
|
+
* Read by {@link build} to decide whether {@link lastSnapshot} is reusable.
|
|
229
|
+
*/
|
|
230
|
+
private notesChanged = true;
|
|
231
|
+
|
|
232
|
+
private notesRead = 0;
|
|
233
|
+
private notesCached = 0;
|
|
234
|
+
private gitCalls = 0;
|
|
235
|
+
private builtAt = "";
|
|
236
|
+
|
|
237
|
+
constructor(opts: WorkspaceCacheOptions) {
|
|
238
|
+
this.cwd = opts.cwd;
|
|
239
|
+
this.vaultRoot = opts.vaultRoot;
|
|
240
|
+
this.now = opts.now ?? (() => new Date());
|
|
241
|
+
this.stalenessTtlMs = opts.stalenessTtlMs ?? DEFAULT_STALENESS_TTL_MS;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The current graph, rebuilt from whatever changed since the last call.
|
|
246
|
+
*
|
|
247
|
+
* Concurrent callers share one build: a second `graph()` arriving while a
|
|
248
|
+
* build is in flight receives the same promise rather than starting a
|
|
249
|
+
* second pass over the disk.
|
|
250
|
+
*/
|
|
251
|
+
graph(): Promise<GraphModel> {
|
|
252
|
+
if (this.inFlightGraph !== null) return this.inFlightGraph;
|
|
253
|
+
const projected = this.snapshot().then((s) => s.model);
|
|
254
|
+
// `snapshot()` may have completed synchronously-enough to have already
|
|
255
|
+
// cleared the slots; only claim the slot if a build is still in flight,
|
|
256
|
+
// so a later caller starts a fresh build rather than reusing this one.
|
|
257
|
+
if (this.inFlight !== null) this.inFlightGraph = projected;
|
|
258
|
+
return projected;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The graph **and** the notes it was built from, from a single build.
|
|
263
|
+
*
|
|
264
|
+
* `graph()` is this with the notes dropped. Callers that derive anything
|
|
265
|
+
* per-note (the tag index, §4.3) must use this instead of pairing `graph()`
|
|
266
|
+
* with a separate vault read, or the two can disagree about which notes
|
|
267
|
+
* exist — see {@link WorkspaceSnapshot}.
|
|
268
|
+
*/
|
|
269
|
+
snapshot(): Promise<WorkspaceSnapshot> {
|
|
270
|
+
if (this.inFlight !== null) return this.inFlight;
|
|
271
|
+
// Serialized against note writes on the same vault (src/core/mutex), so
|
|
272
|
+
// a build cannot read a note file mid-rewrite and cache a torn parse.
|
|
273
|
+
const build = withMutationQueue(join(this.vaultRoot, NOTES_DIR), () => this.build());
|
|
274
|
+
this.inFlight = build;
|
|
275
|
+
// Clear the slots however the build ends, so a failure does not wedge the
|
|
276
|
+
// cache into permanently replaying a rejected promise. Both slots are
|
|
277
|
+
// released together: `inFlightGraph` is only ever a projection of this
|
|
278
|
+
// build, so outliving it would hand the next caller a stale model.
|
|
279
|
+
const clear = (): void => {
|
|
280
|
+
if (this.inFlight === build) {
|
|
281
|
+
this.inFlight = null;
|
|
282
|
+
this.inFlightGraph = null;
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
build.then(clear, clear);
|
|
286
|
+
return build;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Drop the cache entries a changed path affects. Cheap and synchronous:
|
|
291
|
+
* the watcher calls this per event, and the next `graph()` pays for it.
|
|
292
|
+
*/
|
|
293
|
+
invalidate(absPath: string): void {
|
|
294
|
+
const { scope, slug } = classify(absPath, { cwd: this.cwd, vaultRoot: this.vaultRoot });
|
|
295
|
+
if (scope === "vault" && slug !== null) {
|
|
296
|
+
this.notes.delete(slug);
|
|
297
|
+
if (this.building) this.evictedDuringBuild.add(slug);
|
|
298
|
+
} else if (scope === "repo") {
|
|
299
|
+
this.repo = null;
|
|
300
|
+
if (this.building) this.repoDirtiedDuringBuild = true;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Drop everything: a repo scan landed, or the vault root moved. */
|
|
305
|
+
invalidateAll(): void {
|
|
306
|
+
this.notes.clear();
|
|
307
|
+
this.repo = null;
|
|
308
|
+
if (this.building) {
|
|
309
|
+
// Whatever the in-flight build writes back was read before this call,
|
|
310
|
+
// so none of it may count as fresh.
|
|
311
|
+
this.allEvictedDuringBuild = true;
|
|
312
|
+
this.repoDirtiedDuringBuild = true;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
stats(): CacheStats {
|
|
317
|
+
return {
|
|
318
|
+
notesRead: this.notesRead,
|
|
319
|
+
notesCached: this.notesCached,
|
|
320
|
+
gitCalls: this.gitCalls,
|
|
321
|
+
builtAt: this.builtAt,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** One full pass: refresh what changed, then run the pure builder. */
|
|
326
|
+
private async build(): Promise<WorkspaceSnapshot> {
|
|
327
|
+
const spawnsBefore = gitSpawnCount();
|
|
328
|
+
this.building = true;
|
|
329
|
+
this.evictedDuringBuild.clear();
|
|
330
|
+
this.allEvictedDuringBuild = false;
|
|
331
|
+
this.repoDirtiedDuringBuild = false;
|
|
332
|
+
try {
|
|
333
|
+
const notes = await this.refreshNotes();
|
|
334
|
+
const repoFresh = this.repoNeedsRefresh();
|
|
335
|
+
const repo = await this.refreshRepo();
|
|
336
|
+
|
|
337
|
+
// Nothing moved on either side, so the builder would reproduce the
|
|
338
|
+
// previous model byte for byte (`buildGraph` is pure and
|
|
339
|
+
// byte-deterministic for identical inputs). Hand back the *identical*
|
|
340
|
+
// object rather than an equal one — see {@link lastSnapshot}.
|
|
341
|
+
//
|
|
342
|
+
// All four conditions are required, and each one is a way the inputs
|
|
343
|
+
// can differ while the others look quiet:
|
|
344
|
+
//
|
|
345
|
+
// - a previous snapshot exists at all;
|
|
346
|
+
// - `refreshNotes` read nothing and lost nothing (`notesChanged`);
|
|
347
|
+
// - the repository side came from the TTL cache rather than a fresh
|
|
348
|
+
// assessment — a re-assessment can move `staleness` or the git node
|
|
349
|
+
// with no note touched;
|
|
350
|
+
// - no invalidation landed *while* this build was reading, which the
|
|
351
|
+
// deferred-eviction machinery would otherwise apply only after the
|
|
352
|
+
// swap, leaving this snapshot describing inputs already known stale.
|
|
353
|
+
const quiet =
|
|
354
|
+
this.lastSnapshot !== null &&
|
|
355
|
+
!this.notesChanged &&
|
|
356
|
+
!repoFresh &&
|
|
357
|
+
!this.allEvictedDuringBuild &&
|
|
358
|
+
!this.repoDirtiedDuringBuild &&
|
|
359
|
+
this.evictedDuringBuild.size === 0;
|
|
360
|
+
if (quiet && this.lastSnapshot !== null) {
|
|
361
|
+
this.gitCalls += gitSpawnCount() - spawnsBefore;
|
|
362
|
+
this.builtAt = this.now().toISOString();
|
|
363
|
+
return this.lastSnapshot;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Truncated once, here, and then handed to *both* the builder and the
|
|
367
|
+
// snapshot — so a caller deriving per-note data cannot see a note the
|
|
368
|
+
// graph has no node for (§4.3).
|
|
369
|
+
const kept = notes.slice(0, DEFAULT_MAX_NOTES);
|
|
370
|
+
const input: BuildGraphInput = {
|
|
371
|
+
vault: { root: this.vaultRoot, exists: true, noteCount: this.fileCount },
|
|
372
|
+
notes: kept,
|
|
373
|
+
repository: repo?.repository ?? null,
|
|
374
|
+
};
|
|
375
|
+
if (repo?.summaries !== undefined) input.summaries = repo.summaries;
|
|
376
|
+
|
|
377
|
+
this.gitCalls += gitSpawnCount() - spawnsBefore;
|
|
378
|
+
this.builtAt = this.now().toISOString();
|
|
379
|
+
const snapshot: WorkspaceSnapshot = { model: buildGraph(input), notes: Object.freeze(kept) };
|
|
380
|
+
this.lastSnapshot = snapshot;
|
|
381
|
+
return snapshot;
|
|
382
|
+
} finally {
|
|
383
|
+
this.building = false;
|
|
384
|
+
this.applyDeferredInvalidations();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Whether the next {@link refreshRepo} will actually re-assess.
|
|
390
|
+
*
|
|
391
|
+
* Sampled *before* the refresh, because the refresh overwrites the very
|
|
392
|
+
* timestamp the question is about. A fresh assessment can move the git node
|
|
393
|
+
* or the staleness report without any note changing, so it is one of the
|
|
394
|
+
* conditions that forbids snapshot reuse.
|
|
395
|
+
*/
|
|
396
|
+
private repoNeedsRefresh(): boolean {
|
|
397
|
+
if (this.repo === null) return true;
|
|
398
|
+
return this.now().getTime() - this.repo.at >= this.stalenessTtlMs;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Re-apply invalidations that arrived while the build was reading, which
|
|
403
|
+
* the wholesale map/TTL replacement at the end of a build would otherwise
|
|
404
|
+
* have undone. Without this, a note written mid-build stays stale until
|
|
405
|
+
* something else touches it.
|
|
406
|
+
*/
|
|
407
|
+
private applyDeferredInvalidations(): void {
|
|
408
|
+
if (this.allEvictedDuringBuild) this.notes.clear();
|
|
409
|
+
else for (const slug of this.evictedDuringBuild) this.notes.delete(slug);
|
|
410
|
+
if (this.repoDirtiedDuringBuild) this.repo = null;
|
|
411
|
+
this.evictedDuringBuild.clear();
|
|
412
|
+
this.allEvictedDuringBuild = false;
|
|
413
|
+
this.repoDirtiedDuringBuild = false;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Stat every note; re-read only the ones whose mtime or size moved. Notes
|
|
418
|
+
* that disappeared are evicted, so the map never outgrows the vault.
|
|
419
|
+
*/
|
|
420
|
+
private async refreshNotes(): Promise<Note[]> {
|
|
421
|
+
const stats = await statNotes(this.vaultRoot);
|
|
422
|
+
const previousCount = this.notes.size;
|
|
423
|
+
const previousFileCount = this.fileCount;
|
|
424
|
+
this.fileCount = stats.length;
|
|
425
|
+
let read = 0;
|
|
426
|
+
|
|
427
|
+
const next = new Map<string, CachedNote>();
|
|
428
|
+
const out: Note[] = [];
|
|
429
|
+
for (const st of stats) {
|
|
430
|
+
const hit = this.notes.get(st.slug);
|
|
431
|
+
if (hit !== undefined && hit.mtimeMs === st.mtimeMs && hit.size === st.size) {
|
|
432
|
+
this.notesCached += 1;
|
|
433
|
+
next.set(st.slug, hit);
|
|
434
|
+
out.push(hit.note);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
this.notesRead += 1;
|
|
438
|
+
read += 1;
|
|
439
|
+
const note = await getNote(this.vaultRoot, st.slug);
|
|
440
|
+
// Unreadable/malformed notes are skipped but still counted in
|
|
441
|
+
// fileCount, exactly as `readVault` does.
|
|
442
|
+
if (note === null) continue;
|
|
443
|
+
next.set(st.slug, { mtimeMs: st.mtimeMs, size: st.size, note });
|
|
444
|
+
out.push(note);
|
|
445
|
+
}
|
|
446
|
+
// A note vanished if the map shrank without a compensating read; the
|
|
447
|
+
// file count moving covers a malformed file appearing or disappearing,
|
|
448
|
+
// which changes the vault node's `notes` detail without ever parsing.
|
|
449
|
+
this.notesChanged = read > 0 || next.size !== previousCount || this.fileCount !== previousFileCount;
|
|
450
|
+
this.notes = next;
|
|
451
|
+
// `statNotes` yields readdir (slug-ascending) order and sort is stable,
|
|
452
|
+
// so ties break by slug — identical to `readVault`.
|
|
453
|
+
return out.sort((a, b) => b.updated.localeCompare(a.updated));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The repository half, re-assessed only when the TTL has expired. */
|
|
457
|
+
private async refreshRepo(): Promise<Pick<BuildGraphInput, "repository" | "summaries"> | null> {
|
|
458
|
+
const at = this.now().getTime();
|
|
459
|
+
if (this.repo !== null && at - this.repo.at < this.stalenessTtlMs) {
|
|
460
|
+
return this.repo.value;
|
|
461
|
+
}
|
|
462
|
+
const value = await readRepositorySide(this.cwd);
|
|
463
|
+
this.repo = { at, value };
|
|
464
|
+
return value;
|
|
465
|
+
}
|
|
466
|
+
}
|