@untangle-code/storage-local 0.0.0-stage → 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 +661 -0
- package/NOTICE +90 -0
- package/README.md +226 -2
- package/THIRD_PARTY_LICENSES.md +24 -0
- package/dist/abort.d.ts +18 -0
- package/dist/abort.d.ts.map +1 -0
- package/dist/abort.js +14 -0
- package/dist/abort.js.map +1 -0
- package/dist/analysis-cache.d.ts +81 -0
- package/dist/analysis-cache.d.ts.map +1 -0
- package/dist/analysis-cache.js +322 -0
- package/dist/analysis-cache.js.map +1 -0
- package/dist/checkpoint-store.d.ts +22 -0
- package/dist/checkpoint-store.d.ts.map +1 -0
- package/dist/checkpoint-store.js +123 -0
- package/dist/checkpoint-store.js.map +1 -0
- package/dist/embedding-cache.d.ts +38 -0
- package/dist/embedding-cache.d.ts.map +1 -0
- package/dist/embedding-cache.js +229 -0
- package/dist/embedding-cache.js.map +1 -0
- package/dist/fs-errors.d.ts +4 -0
- package/dist/fs-errors.d.ts.map +1 -0
- package/dist/fs-errors.js +14 -0
- package/dist/fs-errors.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/layout.d.ts +50 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +99 -0
- package/dist/layout.js.map +1 -0
- package/dist/local-file-system.d.ts +37 -0
- package/dist/local-file-system.d.ts.map +1 -0
- package/dist/local-file-system.js +193 -0
- package/dist/local-file-system.js.map +1 -0
- package/dist/lock-provider.d.ts +59 -0
- package/dist/lock-provider.d.ts.map +1 -0
- package/dist/lock-provider.js +318 -0
- package/dist/lock-provider.js.map +1 -0
- package/dist/real-time.d.ts +16 -0
- package/dist/real-time.d.ts.map +1 -0
- package/dist/real-time.js +19 -0
- package/dist/real-time.js.map +1 -0
- package/dist/search-journey-store.d.ts +34 -0
- package/dist/search-journey-store.d.ts.map +1 -0
- package/dist/search-journey-store.js +530 -0
- package/dist/search-journey-store.js.map +1 -0
- package/dist/storage.d.ts +42 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +34 -0
- package/dist/storage.js.map +1 -0
- package/package.json +60 -3
- package/src/abort.ts +26 -0
- package/src/analysis-cache.ts +404 -0
- package/src/checkpoint-store.ts +155 -0
- package/src/embedding-cache.ts +282 -0
- package/src/fs-errors.ts +19 -0
- package/src/index.ts +30 -0
- package/src/layout.ts +149 -0
- package/src/local-file-system.ts +232 -0
- package/src/lock-provider.ts +381 -0
- package/src/real-time.ts +18 -0
- package/src/search-journey-store.ts +638 -0
- package/src/storage.ts +98 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent embedding cache under `cache/embeddings/` (architecture.md §12;
|
|
3
|
+
* spec.md §9.4: cache key `embedding_profile_id + SHA256(prepared_text)`,
|
|
4
|
+
* "Unveränderte vorbereitete Texte DÜRFEN NICHT erneut eingebettet werden,
|
|
5
|
+
* sofern der Cache gültig ist").
|
|
6
|
+
*
|
|
7
|
+
* Layout:
|
|
8
|
+
* - one entry file per cached vector, sharded two hex characters deep
|
|
9
|
+
* (`<hash[0:2]>/<hash>.json`) so a large cache never puts thousands of
|
|
10
|
+
* files in one directory;
|
|
11
|
+
* - one small `index.json` manifest tracking, per logical cache key, which
|
|
12
|
+
* entry file holds it, its size, and its last-access time — the LRU
|
|
13
|
+
* bookkeeping used for size-bounded eviction.
|
|
14
|
+
*
|
|
15
|
+
* Every mutation (`set`, and `get` on a hit, since a hit updates recency)
|
|
16
|
+
* rewrites the manifest via atomic temp+rename. This is O(entry count) per
|
|
17
|
+
* call, a documented, accepted simplification for an MVP-sized local cache
|
|
18
|
+
* (see README "Entscheidungen") — not the right shape for a cache with
|
|
19
|
+
* millions of entries, which would want a real embedded index instead of one
|
|
20
|
+
* JSON file.
|
|
21
|
+
*
|
|
22
|
+
* Reads degrade in a controlled way rather than throwing (docs/BUILD_PLAN.md
|
|
23
|
+
* WP-12 acceptance: "Cache-Löschung ist verlustfrei rekonstruierbar (Reads
|
|
24
|
+
* degradieren kontrolliert)"): a missing entry file, a corrupted entry, or a
|
|
25
|
+
* corrupted manifest are all treated as a cache miss (or an empty cache),
|
|
26
|
+
* self-healing the manifest where relevant — never a thrown error, because
|
|
27
|
+
* the cache is documented as disposable and reconstructible from source
|
|
28
|
+
* (architecture.md §12: "Lokale Caches sind löschbar und rekonstruierbar").
|
|
29
|
+
*/
|
|
30
|
+
import * as nodePath from "node:path";
|
|
31
|
+
|
|
32
|
+
import type { Clock, FileSystemPort, PortCallOptions, Random } from "@untangle-code/domain";
|
|
33
|
+
import { sha256Hex } from "@untangle-code/domain";
|
|
34
|
+
|
|
35
|
+
import { assertNotAborted } from "./abort.js";
|
|
36
|
+
import { isNotFoundError } from "./fs-errors.js";
|
|
37
|
+
|
|
38
|
+
/** 512 MiB — an Untangle default, not spec-mandated (spec.md §9.4 leaves the
|
|
39
|
+
* limit open); callers pass their own `maxBytes` where config specifies one. */
|
|
40
|
+
export const DEFAULT_EMBEDDING_CACHE_MAX_BYTES = 512 * 1024 * 1024;
|
|
41
|
+
|
|
42
|
+
const MANIFEST_FILE_NAME = "index.json";
|
|
43
|
+
|
|
44
|
+
export interface EmbeddingCacheStatsV1 {
|
|
45
|
+
entryCount: number;
|
|
46
|
+
totalBytes: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ManifestEntryV1 {
|
|
50
|
+
fileName: string;
|
|
51
|
+
sizeBytes: number;
|
|
52
|
+
lastAccessEpochMs: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ManifestV1 {
|
|
56
|
+
version: 1;
|
|
57
|
+
entries: Record<string, ManifestEntryV1>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface StoredEntryV1 {
|
|
61
|
+
profileId: string;
|
|
62
|
+
vector: number[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function encode(text: string): Uint8Array {
|
|
66
|
+
return new TextEncoder().encode(text);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function signalOptions(options: PortCallOptions | undefined): PortCallOptions | undefined {
|
|
70
|
+
return options?.signal ? { signal: options.signal } : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function totalBytesOf(manifest: ManifestV1): number {
|
|
74
|
+
let total = 0;
|
|
75
|
+
for (const entry of Object.values(manifest.entries)) {
|
|
76
|
+
total += entry.sizeBytes;
|
|
77
|
+
}
|
|
78
|
+
return total;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function pickEvictionCandidate(manifest: ManifestV1, protectedKey: string): string | null {
|
|
82
|
+
const keys = Object.keys(manifest.entries);
|
|
83
|
+
let best: string | null = null;
|
|
84
|
+
let bestAccess = Number.POSITIVE_INFINITY;
|
|
85
|
+
for (const key of keys) {
|
|
86
|
+
if (key === protectedKey && keys.length === 1) {
|
|
87
|
+
// The just-written entry is the sole entry left — evicting it would
|
|
88
|
+
// silently discard the very value `set()` was asked to store. A
|
|
89
|
+
// single entry larger than `maxBytes` is kept anyway (documented edge
|
|
90
|
+
// case, README "Entscheidungen") rather than making `set()` a no-op.
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const entry = manifest.entries[key];
|
|
94
|
+
if (entry !== undefined && entry.lastAccessEpochMs < bestAccess) {
|
|
95
|
+
bestAccess = entry.lastAccessEpochMs;
|
|
96
|
+
best = key;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return best;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Cache key exactly per spec.md §9.4: `embedding_profile_id + SHA256(prepared_text)`.
|
|
104
|
+
* Exported so callers/tests can reason about identity without depending on
|
|
105
|
+
* this class's internal file sharding.
|
|
106
|
+
*/
|
|
107
|
+
export function buildEmbeddingCacheKey(embeddingProfileId: string, preparedText: string): string {
|
|
108
|
+
return `${embeddingProfileId}${sha256Hex(preparedText)}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export class LocalEmbeddingCache {
|
|
112
|
+
private readonly maxBytes: number;
|
|
113
|
+
private manifest: ManifestV1 | null = null;
|
|
114
|
+
|
|
115
|
+
public constructor(
|
|
116
|
+
private readonly fs: FileSystemPort,
|
|
117
|
+
private readonly dir: string,
|
|
118
|
+
private readonly clock: Clock,
|
|
119
|
+
private readonly random: Random,
|
|
120
|
+
options?: { maxBytes?: number },
|
|
121
|
+
) {
|
|
122
|
+
this.maxBytes = options?.maxBytes ?? DEFAULT_EMBEDDING_CACHE_MAX_BYTES;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private manifestPath(): string {
|
|
126
|
+
return nodePath.join(this.dir, MANIFEST_FILE_NAME);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private entryFileName(logicalKey: string): string {
|
|
130
|
+
const hash = sha256Hex(logicalKey);
|
|
131
|
+
return nodePath.join(hash.slice(0, 2), `${hash}.json`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private entryPath(fileName: string): string {
|
|
135
|
+
return nodePath.join(this.dir, fileName);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private async loadManifest(options: PortCallOptions | undefined): Promise<ManifestV1> {
|
|
139
|
+
if (this.manifest !== null) {
|
|
140
|
+
return this.manifest;
|
|
141
|
+
}
|
|
142
|
+
let text: string;
|
|
143
|
+
try {
|
|
144
|
+
text = await this.fs.readTextFile(this.manifestPath(), options);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (isNotFoundError(error)) {
|
|
147
|
+
this.manifest = { version: 1, entries: {} };
|
|
148
|
+
return this.manifest;
|
|
149
|
+
}
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const parsed = JSON.parse(text) as Partial<ManifestV1>;
|
|
154
|
+
this.manifest =
|
|
155
|
+
parsed.entries !== undefined
|
|
156
|
+
? { version: 1, entries: parsed.entries }
|
|
157
|
+
: { version: 1, entries: {} };
|
|
158
|
+
} catch {
|
|
159
|
+
// Corrupted manifest — degrade to an empty cache (see file doc comment).
|
|
160
|
+
this.manifest = { version: 1, entries: {} };
|
|
161
|
+
}
|
|
162
|
+
return this.manifest;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private async persistManifest(
|
|
166
|
+
manifest: ManifestV1,
|
|
167
|
+
options: PortCallOptions | undefined,
|
|
168
|
+
): Promise<void> {
|
|
169
|
+
await this.fs.mkdir(this.dir, { recursive: true, ...signalOptions(options) });
|
|
170
|
+
const tempPath = `${this.manifestPath()}.${this.random.uuid()}.tmp`;
|
|
171
|
+
await this.fs.writeFile(tempPath, encode(JSON.stringify(manifest)), options);
|
|
172
|
+
await this.fs.rename(tempPath, this.manifestPath(), options);
|
|
173
|
+
this.manifest = manifest;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
public async get(
|
|
177
|
+
embeddingProfileId: string,
|
|
178
|
+
preparedText: string,
|
|
179
|
+
options?: PortCallOptions,
|
|
180
|
+
): Promise<number[] | null> {
|
|
181
|
+
assertNotAborted(options, "embedding_cache_get");
|
|
182
|
+
const logicalKey = buildEmbeddingCacheKey(embeddingProfileId, preparedText);
|
|
183
|
+
const manifest = await this.loadManifest(options);
|
|
184
|
+
const entry = manifest.entries[logicalKey];
|
|
185
|
+
if (entry === undefined) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
let text: string;
|
|
189
|
+
try {
|
|
190
|
+
text = await this.fs.readTextFile(this.entryPath(entry.fileName), options);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (!isNotFoundError(error)) {
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
// Manifest references a file that is gone — self-heal and miss.
|
|
196
|
+
delete manifest.entries[logicalKey];
|
|
197
|
+
await this.persistManifest(manifest, options);
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
let stored: StoredEntryV1;
|
|
201
|
+
try {
|
|
202
|
+
stored = JSON.parse(text) as StoredEntryV1;
|
|
203
|
+
} catch {
|
|
204
|
+
delete manifest.entries[logicalKey];
|
|
205
|
+
await this.persistManifest(manifest, options);
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
entry.lastAccessEpochMs = this.clock.nowEpochMs();
|
|
209
|
+
await this.persistManifest(manifest, options);
|
|
210
|
+
return stored.vector;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
public async set(
|
|
214
|
+
embeddingProfileId: string,
|
|
215
|
+
preparedText: string,
|
|
216
|
+
vector: readonly number[],
|
|
217
|
+
options?: PortCallOptions,
|
|
218
|
+
): Promise<void> {
|
|
219
|
+
assertNotAborted(options, "embedding_cache_set");
|
|
220
|
+
const logicalKey = buildEmbeddingCacheKey(embeddingProfileId, preparedText);
|
|
221
|
+
const fileName = this.entryFileName(logicalKey);
|
|
222
|
+
const entryPath = this.entryPath(fileName);
|
|
223
|
+
const stored: StoredEntryV1 = { profileId: embeddingProfileId, vector: Array.from(vector) };
|
|
224
|
+
const bytes = encode(JSON.stringify(stored));
|
|
225
|
+
|
|
226
|
+
await this.fs.mkdir(nodePath.dirname(entryPath), {
|
|
227
|
+
recursive: true,
|
|
228
|
+
...signalOptions(options),
|
|
229
|
+
});
|
|
230
|
+
const tempPath = `${entryPath}.${this.random.uuid()}.tmp`;
|
|
231
|
+
await this.fs.writeFile(tempPath, bytes, options);
|
|
232
|
+
await this.fs.rename(tempPath, entryPath, options);
|
|
233
|
+
|
|
234
|
+
const manifest = await this.loadManifest(options);
|
|
235
|
+
manifest.entries[logicalKey] = {
|
|
236
|
+
fileName,
|
|
237
|
+
sizeBytes: bytes.length,
|
|
238
|
+
lastAccessEpochMs: this.clock.nowEpochMs(),
|
|
239
|
+
};
|
|
240
|
+
await this.evictUntilWithinLimit(manifest, logicalKey, options);
|
|
241
|
+
await this.persistManifest(manifest, options);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private async evictUntilWithinLimit(
|
|
245
|
+
manifest: ManifestV1,
|
|
246
|
+
justWrittenKey: string,
|
|
247
|
+
options: PortCallOptions | undefined,
|
|
248
|
+
): Promise<void> {
|
|
249
|
+
let total = totalBytesOf(manifest);
|
|
250
|
+
while (total > this.maxBytes) {
|
|
251
|
+
const victim = pickEvictionCandidate(manifest, justWrittenKey);
|
|
252
|
+
if (victim === null) {
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
const entry = manifest.entries[victim];
|
|
256
|
+
delete manifest.entries[victim];
|
|
257
|
+
if (entry !== undefined) {
|
|
258
|
+
total -= entry.sizeBytes;
|
|
259
|
+
try {
|
|
260
|
+
await this.fs.remove(this.entryPath(entry.fileName), options);
|
|
261
|
+
} catch {
|
|
262
|
+
// Best-effort: the manifest entry is already gone either way, so a
|
|
263
|
+
// future `get()` for this key will miss regardless.
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
public async stats(options?: PortCallOptions): Promise<EmbeddingCacheStatsV1> {
|
|
270
|
+
assertNotAborted(options, "embedding_cache_stats");
|
|
271
|
+
const manifest = await this.loadManifest(options);
|
|
272
|
+
return { entryCount: Object.keys(manifest.entries).length, totalBytes: totalBytesOf(manifest) };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Explicit, whole-cache removal (the one "clear everything" operation this
|
|
276
|
+
* class exposes — always naming its exact target: this cache's own directory). */
|
|
277
|
+
public async clear(options?: PortCallOptions): Promise<void> {
|
|
278
|
+
assertNotAborted(options, "embedding_cache_clear");
|
|
279
|
+
await this.fs.remove(this.dir, { recursive: true, ...signalOptions(options) });
|
|
280
|
+
this.manifest = { version: 1, entries: {} };
|
|
281
|
+
}
|
|
282
|
+
}
|
package/src/fs-errors.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Shared Node `fs` error-code checks used across this package's adapters. */
|
|
2
|
+
|
|
3
|
+
export function isNotFoundError(error: unknown): boolean {
|
|
4
|
+
return (
|
|
5
|
+
typeof error === "object" &&
|
|
6
|
+
error !== null &&
|
|
7
|
+
"code" in error &&
|
|
8
|
+
(error as { code?: unknown }).code === "ENOENT"
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isAlreadyExistsError(error: unknown): boolean {
|
|
13
|
+
return (
|
|
14
|
+
typeof error === "object" &&
|
|
15
|
+
error !== null &&
|
|
16
|
+
"code" in error &&
|
|
17
|
+
(error as { code?: unknown }).code === "EEXIST"
|
|
18
|
+
);
|
|
19
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @untangle-code/storage-local — local state under `.untangle/`
|
|
3
|
+
* (architecture.md §12, §21.1–§21.2; spec.md §11.2, §12.3, §24).
|
|
4
|
+
*
|
|
5
|
+
* This module is the ONLY public export of the package.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// `.untangle/` layout (architecture.md §12)
|
|
9
|
+
export * from "./layout.js";
|
|
10
|
+
|
|
11
|
+
// Path-sandboxed `FileSystemPort` adapter (architecture.md §2.1, §23.1)
|
|
12
|
+
export * from "./local-file-system.js";
|
|
13
|
+
|
|
14
|
+
// File-based `LockProvider` (architecture.md §21.1–§21.2)
|
|
15
|
+
export * from "./lock-provider.js";
|
|
16
|
+
|
|
17
|
+
// Atomic local checkpoint mirror (architecture.md §12, §13.3)
|
|
18
|
+
export * from "./checkpoint-store.js";
|
|
19
|
+
|
|
20
|
+
// Persistent, size-bounded embedding cache (spec.md §9.4)
|
|
21
|
+
export * from "./embedding-cache.js";
|
|
22
|
+
|
|
23
|
+
// Inhaltsadressierte Caches fuer Parse, Chunk und Komplexitaet (WP-100)
|
|
24
|
+
export * from "./analysis-cache.js";
|
|
25
|
+
|
|
26
|
+
// Explizit aktivierte, lokale Dynamic-Product-Complexity-Sessions (Welle 9)
|
|
27
|
+
export * from "./search-journey-store.js";
|
|
28
|
+
|
|
29
|
+
// Convenience wiring of the above
|
|
30
|
+
export * from "./storage.js";
|
package/src/layout.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.untangle/` local state directory layout (architecture.md §12).
|
|
3
|
+
*
|
|
4
|
+
* ```text
|
|
5
|
+
* .untangle/
|
|
6
|
+
* ├─ cache/
|
|
7
|
+
* │ ├─ embeddings/
|
|
8
|
+
* │ ├─ analysis/ (WP-100: parse/, chunk/, metrics/)
|
|
9
|
+
* │ ├─ parser/
|
|
10
|
+
* │ └─ git-blobs/
|
|
11
|
+
* ├─ jobs/
|
|
12
|
+
* ├─ locks/
|
|
13
|
+
* ├─ product-complexity/
|
|
14
|
+
* │ └─ sessions/
|
|
15
|
+
* ├─ artifacts/
|
|
16
|
+
* │ ├─ diffs/
|
|
17
|
+
* │ └─ visualizations/
|
|
18
|
+
* └─ logs/
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* Pure path arithmetic only — no I/O. `ensureStateDirLayout` is the one
|
|
22
|
+
* function that actually touches the filesystem, via an injected
|
|
23
|
+
* `FileSystemPort` (never `node:fs` directly), so layout construction stays
|
|
24
|
+
* testable without a real disk.
|
|
25
|
+
*/
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
|
|
28
|
+
import type { FileSystemPort, PortCallOptions } from "@untangle-code/domain";
|
|
29
|
+
|
|
30
|
+
import { assertNotAborted } from "./abort.js";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Die drei inhaltsadressierten Analyse-Caches (WP-100). Sie liegen unter
|
|
34
|
+
* `cache/analysis/`, getrennt nach Art, damit jeder seine eigene
|
|
35
|
+
* Groessengrenze und seine eigene Trefferquote hat.
|
|
36
|
+
*/
|
|
37
|
+
export interface StateAnalysisCacheLayout {
|
|
38
|
+
root: string;
|
|
39
|
+
parse: string;
|
|
40
|
+
chunk: string;
|
|
41
|
+
metrics: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface StateCacheLayout {
|
|
45
|
+
root: string;
|
|
46
|
+
embeddings: string;
|
|
47
|
+
analysis: StateAnalysisCacheLayout;
|
|
48
|
+
parser: string;
|
|
49
|
+
gitBlobs: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface StateArtifactsLayout {
|
|
53
|
+
root: string;
|
|
54
|
+
diffs: string;
|
|
55
|
+
visualizations: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The full `.untangle/` layout rooted at one absolute directory. */
|
|
59
|
+
export interface StateDirLayout {
|
|
60
|
+
/** Absolute path to the state directory itself (e.g. `<workspace>/.untangle`). */
|
|
61
|
+
root: string;
|
|
62
|
+
cache: StateCacheLayout;
|
|
63
|
+
jobs: string;
|
|
64
|
+
locks: string;
|
|
65
|
+
productComplexity: {
|
|
66
|
+
root: string;
|
|
67
|
+
sessions: string;
|
|
68
|
+
};
|
|
69
|
+
artifacts: StateArtifactsLayout;
|
|
70
|
+
logs: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Builds the layout's paths under `root`. Pure; performs no I/O. */
|
|
74
|
+
export function buildStateDirLayout(root: string): StateDirLayout {
|
|
75
|
+
const cacheRoot = path.join(root, "cache");
|
|
76
|
+
const analysisRoot = path.join(cacheRoot, "analysis");
|
|
77
|
+
const artifactsRoot = path.join(root, "artifacts");
|
|
78
|
+
const productComplexityRoot = path.join(root, "product-complexity");
|
|
79
|
+
return {
|
|
80
|
+
root,
|
|
81
|
+
cache: {
|
|
82
|
+
root: cacheRoot,
|
|
83
|
+
embeddings: path.join(cacheRoot, "embeddings"),
|
|
84
|
+
analysis: {
|
|
85
|
+
root: analysisRoot,
|
|
86
|
+
parse: path.join(analysisRoot, "parse"),
|
|
87
|
+
chunk: path.join(analysisRoot, "chunk"),
|
|
88
|
+
metrics: path.join(analysisRoot, "metrics"),
|
|
89
|
+
},
|
|
90
|
+
parser: path.join(cacheRoot, "parser"),
|
|
91
|
+
gitBlobs: path.join(cacheRoot, "git-blobs"),
|
|
92
|
+
},
|
|
93
|
+
jobs: path.join(root, "jobs"),
|
|
94
|
+
locks: path.join(root, "locks"),
|
|
95
|
+
productComplexity: {
|
|
96
|
+
root: productComplexityRoot,
|
|
97
|
+
sessions: path.join(productComplexityRoot, "sessions"),
|
|
98
|
+
},
|
|
99
|
+
artifacts: {
|
|
100
|
+
root: artifactsRoot,
|
|
101
|
+
diffs: path.join(artifactsRoot, "diffs"),
|
|
102
|
+
visualizations: path.join(artifactsRoot, "visualizations"),
|
|
103
|
+
},
|
|
104
|
+
logs: path.join(root, "logs"),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Every directory the layout declares, in an order safe for sequential `mkdir`. */
|
|
109
|
+
export function listStateDirLayoutPaths(layout: StateDirLayout): string[] {
|
|
110
|
+
return [
|
|
111
|
+
layout.root,
|
|
112
|
+
layout.cache.root,
|
|
113
|
+
layout.cache.embeddings,
|
|
114
|
+
layout.cache.analysis.root,
|
|
115
|
+
layout.cache.analysis.parse,
|
|
116
|
+
layout.cache.analysis.chunk,
|
|
117
|
+
layout.cache.analysis.metrics,
|
|
118
|
+
layout.cache.parser,
|
|
119
|
+
layout.cache.gitBlobs,
|
|
120
|
+
layout.jobs,
|
|
121
|
+
layout.locks,
|
|
122
|
+
layout.productComplexity.root,
|
|
123
|
+
layout.productComplexity.sessions,
|
|
124
|
+
layout.artifacts.root,
|
|
125
|
+
layout.artifacts.diffs,
|
|
126
|
+
layout.artifacts.visualizations,
|
|
127
|
+
layout.logs,
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Creates every directory of the layout (idempotent — safe to call on every
|
|
133
|
+
* startup). Directories are created one at a time with `recursive: true` so
|
|
134
|
+
* a partially-created layout from a previous aborted run is completed
|
|
135
|
+
* without error.
|
|
136
|
+
*/
|
|
137
|
+
export async function ensureStateDirLayout(
|
|
138
|
+
fs: FileSystemPort,
|
|
139
|
+
layout: StateDirLayout,
|
|
140
|
+
options?: PortCallOptions,
|
|
141
|
+
): Promise<void> {
|
|
142
|
+
for (const dir of listStateDirLayoutPaths(layout)) {
|
|
143
|
+
assertNotAborted(options, "ensure_state_dir_layout");
|
|
144
|
+
await fs.mkdir(dir, {
|
|
145
|
+
recursive: true,
|
|
146
|
+
...(options?.signal ? { signal: options.signal } : {}),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|