opencode-telos 1.0.0 → 1.0.2
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/code-intelligence/ast/cache.d.ts.map +1 -1
- package/dist/code-intelligence/ast/cache.js +8 -5
- package/dist/code-intelligence/ast/cache.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/opencode/hooks.d.ts +1 -1
- package/dist/opencode/hooks.d.ts.map +1 -1
- package/dist/opencode/hooks.js +34 -40
- package/dist/opencode/hooks.js.map +1 -1
- package/dist/opencode/tools.d.ts.map +1 -1
- package/dist/opencode/tools.js +9 -8
- package/dist/opencode/tools.js.map +1 -1
- package/dist/sdd/cache/atomic.d.ts +3 -0
- package/dist/sdd/cache/atomic.d.ts.map +1 -0
- package/dist/sdd/cache/atomic.js +35 -0
- package/dist/sdd/cache/atomic.js.map +1 -0
- package/dist/sdd/cache/fingerprint.d.ts +18 -0
- package/dist/sdd/cache/fingerprint.d.ts.map +1 -0
- package/dist/sdd/cache/fingerprint.js +99 -0
- package/dist/sdd/cache/fingerprint.js.map +1 -0
- package/dist/sdd/cache/manager.d.ts +24 -10
- package/dist/sdd/cache/manager.d.ts.map +1 -1
- package/dist/sdd/cache/manager.js +184 -33
- package/dist/sdd/cache/manager.js.map +1 -1
- package/dist/sdd/cache/snapshot-store.d.ts +2 -1
- package/dist/sdd/cache/snapshot-store.d.ts.map +1 -1
- package/dist/sdd/cache/snapshot-store.js +13 -6
- package/dist/sdd/cache/snapshot-store.js.map +1 -1
- package/dist/sdd/persistence/sqlite.d.ts.map +1 -1
- package/dist/sdd/persistence/sqlite.js +66 -41
- package/dist/sdd/persistence/sqlite.js.map +1 -1
- package/dist/sdd/persistence/yaml.d.ts.map +1 -1
- package/dist/sdd/persistence/yaml.js +33 -25
- package/dist/sdd/persistence/yaml.js.map +1 -1
- package/package.json +1 -1
- package/src/code-intelligence/ast/cache.ts +11 -6
- package/src/index.ts +4 -1
- package/src/opencode/hooks.ts +34 -39
- package/src/opencode/tools.ts +9 -8
- package/src/sdd/cache/atomic.ts +30 -0
- package/src/sdd/cache/fingerprint.ts +98 -0
- package/src/sdd/cache/manager.ts +182 -39
- package/src/sdd/cache/snapshot-store.ts +14 -7
- package/src/sdd/persistence/sqlite.ts +50 -24
- package/src/sdd/persistence/yaml.ts +40 -26
package/src/sdd/cache/manager.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "fs"
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, statSync, openSync, fsyncSync, closeSync } from "fs"
|
|
2
2
|
import { join, dirname } from "path"
|
|
3
3
|
import { createHash } from "crypto"
|
|
4
4
|
import { PerTypeGraphCache, IncrementalGraphHash } from "../graph/index.js"
|
|
5
5
|
import type { AnyNode, NodeType, Relationship } from "../domain/types.js"
|
|
6
6
|
import { getGraphSnapshotStore } from "./snapshot-store.js"
|
|
7
7
|
import type { KnowledgeGraph } from "../domain/types.js"
|
|
8
|
+
import { atomicWriteFile } from "./atomic.js"
|
|
9
|
+
import { configFingerprint, graphFingerprint, sourceFingerprint } from "./fingerprint.js"
|
|
8
10
|
|
|
9
11
|
// ── Cache Entry Types ────────────────────────────────────────────────
|
|
10
12
|
|
|
@@ -22,15 +24,18 @@ interface ToolCacheEntry {
|
|
|
22
24
|
lastAccess: number
|
|
23
25
|
toolName: string
|
|
24
26
|
argsHash: string
|
|
25
|
-
|
|
27
|
+
graphFingerprint: string
|
|
28
|
+
configFingerprint: string
|
|
29
|
+
sourceFingerprint?: string
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
interface AnalysisCacheEntry {
|
|
29
33
|
result: unknown
|
|
30
34
|
timestamp: number
|
|
31
35
|
lastAccess: number
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
graphFingerprint: string
|
|
37
|
+
configFingerprint: string
|
|
38
|
+
sourceFingerprint?: string
|
|
34
39
|
type: string
|
|
35
40
|
}
|
|
36
41
|
|
|
@@ -38,13 +43,23 @@ interface PersistentCacheData {
|
|
|
38
43
|
version: number
|
|
39
44
|
toolResponses: Record<string, ToolCacheEntry>
|
|
40
45
|
analysisResults: Record<string, AnalysisCacheEntry>
|
|
41
|
-
graphSnapshot
|
|
46
|
+
graphSnapshot?: {
|
|
42
47
|
nodeCount: number
|
|
43
48
|
relationshipCount: number
|
|
44
49
|
version: string
|
|
45
50
|
lastModified: number
|
|
46
51
|
} | null
|
|
47
|
-
graphHash
|
|
52
|
+
graphHash?: string
|
|
53
|
+
configFingerprint?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface InvalidationEvent {
|
|
57
|
+
id: string
|
|
58
|
+
timestamp: number
|
|
59
|
+
pid: number
|
|
60
|
+
nodeTypes: string[]
|
|
61
|
+
relTypes: string[]
|
|
62
|
+
full: boolean
|
|
48
63
|
}
|
|
49
64
|
|
|
50
65
|
// ── Granular Invalidation Tracking ───────────────────────────────────
|
|
@@ -63,6 +78,8 @@ const TOOL_RESPONSE_TTL = 5 * 60 * 1000 // 5 minutes
|
|
|
63
78
|
const ANALYSIS_TTL = 3 * 60 * 1000 // 3 minutes
|
|
64
79
|
const PERSISTENT_CACHE_FILE = ".sdd/cache.json"
|
|
65
80
|
const INVALIDATION_FILE = ".sdd/cache-invalidated.json"
|
|
81
|
+
const INVALIDATION_JOURNAL_FILE = ".sdd/cache-events.jsonl"
|
|
82
|
+
const INVALIDATION_JOURNAL_MAX_BYTES = 1024 * 1024
|
|
66
83
|
|
|
67
84
|
export class CacheManager {
|
|
68
85
|
private projectDir: string
|
|
@@ -88,6 +105,9 @@ export class CacheManager {
|
|
|
88
105
|
|
|
89
106
|
// Incremental graph hash
|
|
90
107
|
private graphHash = new IncrementalGraphHash()
|
|
108
|
+
private sourceFingerprintCache: { signature: string; fingerprint: string } | null = null
|
|
109
|
+
private invalidationJournalOffset = 0
|
|
110
|
+
private invalidationEventCounter = 0
|
|
91
111
|
|
|
92
112
|
// Statistics
|
|
93
113
|
private stats = {
|
|
@@ -102,6 +122,7 @@ export class CacheManager {
|
|
|
102
122
|
this.projectDir = projectDir
|
|
103
123
|
this.loadInvalidationTracker()
|
|
104
124
|
this.invalidationVersionOnWrite = this.invalidation.version
|
|
125
|
+
try { this.invalidationJournalOffset = statSync(join(projectDir, INVALIDATION_JOURNAL_FILE)).size } catch {}
|
|
105
126
|
}
|
|
106
127
|
|
|
107
128
|
// ── Tool Response Cache ───────────────────────────────────────────
|
|
@@ -111,7 +132,8 @@ export class CacheManager {
|
|
|
111
132
|
* Checks: TTL (using lastAccess for freshness), graph version, invalidation status.
|
|
112
133
|
* F: Uses lazy revalidation — checks version before clearing.
|
|
113
134
|
*/
|
|
114
|
-
getToolResponse(toolName: string, args: Record<string, unknown>,
|
|
135
|
+
getToolResponse(toolName: string, args: Record<string, unknown>, currentGraphFingerprint: string): string | null {
|
|
136
|
+
this.refreshExternalInvalidation()
|
|
115
137
|
const key = this.toolCacheKey(toolName, args)
|
|
116
138
|
const entry = this.toolResponses.get(key)
|
|
117
139
|
|
|
@@ -128,6 +150,7 @@ export class CacheManager {
|
|
|
128
150
|
this.stats.toolMisses++
|
|
129
151
|
return null
|
|
130
152
|
}
|
|
153
|
+
this.invalidationVersionOnWrite = this.invalidation.version
|
|
131
154
|
}
|
|
132
155
|
|
|
133
156
|
// B: Check TTL using lastAccess (not timestamp) — entries accessed recently survive restore
|
|
@@ -139,7 +162,14 @@ export class CacheManager {
|
|
|
139
162
|
}
|
|
140
163
|
|
|
141
164
|
// Check graph version consistency
|
|
142
|
-
|
|
165
|
+
const currentConfigFingerprint = configFingerprint(this.projectDir)
|
|
166
|
+
if (entry.graphFingerprint !== currentGraphFingerprint || entry.configFingerprint !== currentConfigFingerprint) {
|
|
167
|
+
this.toolResponses.delete(key)
|
|
168
|
+
this.stats.toolMisses++
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (this.toolNeedsSource(toolName) && entry.sourceFingerprint !== this.getSourceFingerprint()) {
|
|
143
173
|
this.toolResponses.delete(key)
|
|
144
174
|
this.stats.toolMisses++
|
|
145
175
|
return null
|
|
@@ -154,7 +184,7 @@ export class CacheManager {
|
|
|
154
184
|
/**
|
|
155
185
|
* Cache a tool response.
|
|
156
186
|
*/
|
|
157
|
-
setToolResponse(toolName: string, args: Record<string, unknown>, response: string,
|
|
187
|
+
setToolResponse(toolName: string, args: Record<string, unknown>, response: string, currentGraphFingerprint: string): void {
|
|
158
188
|
const key = this.toolCacheKey(toolName, args)
|
|
159
189
|
const now = Date.now()
|
|
160
190
|
this.toolResponses.set(key, {
|
|
@@ -163,7 +193,9 @@ export class CacheManager {
|
|
|
163
193
|
lastAccess: now,
|
|
164
194
|
toolName,
|
|
165
195
|
argsHash: this.toolCacheKey(toolName, args).split(':')[1] || '',
|
|
166
|
-
|
|
196
|
+
graphFingerprint: currentGraphFingerprint,
|
|
197
|
+
configFingerprint: configFingerprint(this.projectDir),
|
|
198
|
+
sourceFingerprint: this.toolNeedsSource(toolName) ? this.getSourceFingerprint() : undefined,
|
|
167
199
|
})
|
|
168
200
|
|
|
169
201
|
// Evict old entries if cache is too large
|
|
@@ -179,7 +211,8 @@ export class CacheManager {
|
|
|
179
211
|
* Revalidates based on graph state.
|
|
180
212
|
* F: Uses lazy revalidation.
|
|
181
213
|
*/
|
|
182
|
-
getAnalysisResult(type: string,
|
|
214
|
+
getAnalysisResult(type: string, currentGraphFingerprint: string): unknown | null {
|
|
215
|
+
this.refreshExternalInvalidation()
|
|
183
216
|
const key = `analysis:${type}`
|
|
184
217
|
const entry = this.analysisResults.get(key)
|
|
185
218
|
|
|
@@ -196,6 +229,7 @@ export class CacheManager {
|
|
|
196
229
|
this.stats.analysisMisses++
|
|
197
230
|
return null
|
|
198
231
|
}
|
|
232
|
+
this.invalidationVersionOnWrite = this.invalidation.version
|
|
199
233
|
}
|
|
200
234
|
|
|
201
235
|
// B: Check TTL using lastAccess
|
|
@@ -206,8 +240,14 @@ export class CacheManager {
|
|
|
206
240
|
return null
|
|
207
241
|
}
|
|
208
242
|
|
|
209
|
-
// Revalidation:
|
|
210
|
-
if (entry.
|
|
243
|
+
// Revalidation: graph and configuration content must match exactly.
|
|
244
|
+
if (entry.graphFingerprint !== currentGraphFingerprint || entry.configFingerprint !== configFingerprint(this.projectDir)) {
|
|
245
|
+
this.analysisResults.delete(key)
|
|
246
|
+
this.stats.analysisMisses++
|
|
247
|
+
return null
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (this.analysisNeedsSource(type) && entry.sourceFingerprint !== this.getSourceFingerprint()) {
|
|
211
251
|
this.analysisResults.delete(key)
|
|
212
252
|
this.stats.analysisMisses++
|
|
213
253
|
return null
|
|
@@ -222,15 +262,16 @@ export class CacheManager {
|
|
|
222
262
|
/**
|
|
223
263
|
* Cache an analysis result.
|
|
224
264
|
*/
|
|
225
|
-
setAnalysisResult(type: string, result: unknown,
|
|
265
|
+
setAnalysisResult(type: string, result: unknown, currentGraphFingerprint: string): void {
|
|
226
266
|
const key = `analysis:${type}`
|
|
227
267
|
const now = Date.now()
|
|
228
268
|
this.analysisResults.set(key, {
|
|
229
269
|
result,
|
|
230
270
|
timestamp: now,
|
|
231
271
|
lastAccess: now,
|
|
232
|
-
|
|
233
|
-
|
|
272
|
+
graphFingerprint: currentGraphFingerprint,
|
|
273
|
+
configFingerprint: configFingerprint(this.projectDir),
|
|
274
|
+
sourceFingerprint: this.analysisNeedsSource(type) ? this.getSourceFingerprint() : undefined,
|
|
234
275
|
type,
|
|
235
276
|
})
|
|
236
277
|
}
|
|
@@ -246,6 +287,7 @@ export class CacheManager {
|
|
|
246
287
|
}
|
|
247
288
|
this.invalidation.version++
|
|
248
289
|
this.saveInvalidationTracker()
|
|
290
|
+
this.appendInvalidationEvent({ nodeTypes: [], relTypes: [], full: false })
|
|
249
291
|
}
|
|
250
292
|
|
|
251
293
|
/**
|
|
@@ -257,6 +299,7 @@ export class CacheManager {
|
|
|
257
299
|
}
|
|
258
300
|
this.invalidation.version++
|
|
259
301
|
this.saveInvalidationTracker()
|
|
302
|
+
this.appendInvalidationEvent({ nodeTypes: [], relTypes: types, full: false })
|
|
260
303
|
}
|
|
261
304
|
|
|
262
305
|
/**
|
|
@@ -274,6 +317,7 @@ export class CacheManager {
|
|
|
274
317
|
this.invalidation.version++
|
|
275
318
|
this.stats.invalidations++
|
|
276
319
|
this.saveInvalidationTracker()
|
|
320
|
+
this.appendInvalidationEvent({ nodeTypes: [], relTypes: [], full: true })
|
|
277
321
|
}
|
|
278
322
|
|
|
279
323
|
/**
|
|
@@ -322,6 +366,7 @@ export class CacheManager {
|
|
|
322
366
|
this.invalidation.dirtyTypes.add(nodeType)
|
|
323
367
|
this.invalidation.version++
|
|
324
368
|
this.saveInvalidationTracker()
|
|
369
|
+
this.appendInvalidationEvent({ nodeTypes: [nodeType], relTypes: [], full: false })
|
|
325
370
|
}
|
|
326
371
|
|
|
327
372
|
// ── Persistent Cache (Cross-Session) ──────────────────────────────
|
|
@@ -338,6 +383,10 @@ export class CacheManager {
|
|
|
338
383
|
if (Date.now() - (data.timestamp || 0) > 60 * 60 * 1000) {
|
|
339
384
|
return null
|
|
340
385
|
}
|
|
386
|
+
if (data.version !== 2 || !data.toolResponses || !data.analysisResults ||
|
|
387
|
+
typeof data.toolResponses !== "object" || typeof data.analysisResults !== "object") {
|
|
388
|
+
return null
|
|
389
|
+
}
|
|
341
390
|
return data
|
|
342
391
|
} catch {
|
|
343
392
|
return null
|
|
@@ -351,7 +400,7 @@ export class CacheManager {
|
|
|
351
400
|
const path = join(this.projectDir, PERSISTENT_CACHE_FILE)
|
|
352
401
|
const dir = dirname(path)
|
|
353
402
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
354
|
-
|
|
403
|
+
atomicWriteFile(path, JSON.stringify({ ...data, timestamp: Date.now() }, null, 2))
|
|
355
404
|
}
|
|
356
405
|
|
|
357
406
|
/**
|
|
@@ -373,7 +422,9 @@ export class CacheManager {
|
|
|
373
422
|
lastAccess: entry.lastAccess || entry.timestamp,
|
|
374
423
|
toolName: entry.toolName,
|
|
375
424
|
argsHash: entry.argsHash,
|
|
376
|
-
|
|
425
|
+
graphFingerprint: entry.graphFingerprint,
|
|
426
|
+
configFingerprint: entry.configFingerprint || "",
|
|
427
|
+
sourceFingerprint: entry.sourceFingerprint,
|
|
377
428
|
})
|
|
378
429
|
restored++
|
|
379
430
|
}
|
|
@@ -386,8 +437,9 @@ export class CacheManager {
|
|
|
386
437
|
result: entry.result,
|
|
387
438
|
timestamp: entry.timestamp,
|
|
388
439
|
lastAccess: entry.lastAccess || entry.timestamp,
|
|
389
|
-
|
|
390
|
-
|
|
440
|
+
graphFingerprint: entry.graphFingerprint,
|
|
441
|
+
configFingerprint: entry.configFingerprint || "",
|
|
442
|
+
sourceFingerprint: entry.sourceFingerprint,
|
|
391
443
|
type: key.replace("analysis:", ""),
|
|
392
444
|
})
|
|
393
445
|
restored++
|
|
@@ -409,7 +461,9 @@ export class CacheManager {
|
|
|
409
461
|
lastAccess: entry.lastAccess,
|
|
410
462
|
toolName: entry.toolName,
|
|
411
463
|
argsHash: entry.argsHash,
|
|
412
|
-
|
|
464
|
+
graphFingerprint: entry.graphFingerprint,
|
|
465
|
+
configFingerprint: entry.configFingerprint,
|
|
466
|
+
sourceFingerprint: entry.sourceFingerprint,
|
|
413
467
|
}
|
|
414
468
|
}
|
|
415
469
|
|
|
@@ -419,8 +473,9 @@ export class CacheManager {
|
|
|
419
473
|
result: entry.result,
|
|
420
474
|
timestamp: entry.timestamp,
|
|
421
475
|
lastAccess: entry.lastAccess,
|
|
422
|
-
|
|
423
|
-
|
|
476
|
+
graphFingerprint: entry.graphFingerprint,
|
|
477
|
+
configFingerprint: entry.configFingerprint,
|
|
478
|
+
sourceFingerprint: entry.sourceFingerprint,
|
|
424
479
|
type: key.replace("analysis:", ""),
|
|
425
480
|
}
|
|
426
481
|
}
|
|
@@ -429,8 +484,7 @@ export class CacheManager {
|
|
|
429
484
|
version: 2,
|
|
430
485
|
toolResponses,
|
|
431
486
|
analysisResults,
|
|
432
|
-
|
|
433
|
-
graphHash: this.graphHash.getHash(),
|
|
487
|
+
configFingerprint: configFingerprint(this.projectDir),
|
|
434
488
|
})
|
|
435
489
|
}
|
|
436
490
|
|
|
@@ -438,10 +492,10 @@ export class CacheManager {
|
|
|
438
492
|
* Save graph snapshot to disk (G).
|
|
439
493
|
* Called after graph mutations and on dispose.
|
|
440
494
|
*/
|
|
441
|
-
saveGraphSnapshot(graph: KnowledgeGraph): void {
|
|
495
|
+
saveGraphSnapshot(graph: KnowledgeGraph, sourceSignature = ""): void {
|
|
442
496
|
try {
|
|
443
497
|
const store = getGraphSnapshotStore(this.projectDir)
|
|
444
|
-
store.save(graph,
|
|
498
|
+
store.save(graph, graphFingerprint(graph), sourceSignature)
|
|
445
499
|
} catch {}
|
|
446
500
|
}
|
|
447
501
|
|
|
@@ -449,7 +503,7 @@ export class CacheManager {
|
|
|
449
503
|
* Load graph snapshot from disk (G).
|
|
450
504
|
* Returns null if no valid snapshot exists.
|
|
451
505
|
*/
|
|
452
|
-
loadGraphSnapshot(): { graph: KnowledgeGraph; graphHash: string } | null {
|
|
506
|
+
loadGraphSnapshot(): { graph: KnowledgeGraph; graphHash: string; sourceSignature: string } | null {
|
|
453
507
|
try {
|
|
454
508
|
const store = getGraphSnapshotStore(this.projectDir)
|
|
455
509
|
return store.load()
|
|
@@ -475,6 +529,7 @@ export class CacheManager {
|
|
|
475
529
|
* Uses file-based locking + PID liveness check for robust cross-process coordination.
|
|
476
530
|
*/
|
|
477
531
|
checkCrossProcessInvalidation(graphPath: string): boolean {
|
|
532
|
+
if (this.refreshExternalInvalidation()) return true
|
|
478
533
|
const lockPath = join(this.projectDir, ".sdd", ".cache-lock")
|
|
479
534
|
try {
|
|
480
535
|
if (existsSync(lockPath)) {
|
|
@@ -516,21 +571,26 @@ export class CacheManager {
|
|
|
516
571
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
517
572
|
|
|
518
573
|
try {
|
|
519
|
-
// Try to create lock file
|
|
520
574
|
if (existsSync(lockPath)) {
|
|
521
575
|
const existing = JSON.parse(readFileSync(lockPath, "utf-8"))
|
|
522
|
-
|
|
523
|
-
if (Date.now() - existing.timestamp
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
return false
|
|
576
|
+
if (existing.pid === process.pid) return true
|
|
577
|
+
if (Date.now() - existing.timestamp <= 30000) return false
|
|
578
|
+
try {
|
|
579
|
+
if (existing.pid) process.kill(existing.pid, 0)
|
|
580
|
+
return false
|
|
581
|
+
} catch {
|
|
582
|
+
try { unlinkSync(lockPath) } catch { return false }
|
|
527
583
|
}
|
|
528
584
|
}
|
|
529
585
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
586
|
+
// wx/O_EXCL makes acquisition atomic when two OpenCode processes write together.
|
|
587
|
+
const descriptor = openSync(lockPath, "wx", 0o600)
|
|
588
|
+
try {
|
|
589
|
+
writeFileSync(descriptor, JSON.stringify({ pid: process.pid, timestamp: Date.now() }), "utf-8")
|
|
590
|
+
fsyncSync(descriptor)
|
|
591
|
+
} finally {
|
|
592
|
+
closeSync(descriptor)
|
|
593
|
+
}
|
|
534
594
|
return true
|
|
535
595
|
} catch {
|
|
536
596
|
return false
|
|
@@ -572,6 +632,7 @@ export class CacheManager {
|
|
|
572
632
|
this.invalidation.version++
|
|
573
633
|
this.stats.invalidations++
|
|
574
634
|
this.saveInvalidationTracker()
|
|
635
|
+
this.appendInvalidationEvent({ nodeTypes: [], relTypes: [], full: true })
|
|
575
636
|
|
|
576
637
|
// Clear persistent cache on disk
|
|
577
638
|
let disk = false
|
|
@@ -620,6 +681,47 @@ export class CacheManager {
|
|
|
620
681
|
this.invalidationVersionOnWrite = this.invalidation.version
|
|
621
682
|
}
|
|
622
683
|
|
|
684
|
+
/** Refresh cache state from durable invalidation events written by another process. */
|
|
685
|
+
refreshExternalInvalidation(): boolean {
|
|
686
|
+
const journalPath = join(this.projectDir, INVALIDATION_JOURNAL_FILE)
|
|
687
|
+
if (!existsSync(journalPath)) return false
|
|
688
|
+
|
|
689
|
+
try {
|
|
690
|
+
const size = statSync(journalPath).size
|
|
691
|
+
if (size < this.invalidationJournalOffset) this.invalidationJournalOffset = 0
|
|
692
|
+
if (size === this.invalidationJournalOffset) return false
|
|
693
|
+
|
|
694
|
+
const content = readFileSync(journalPath)
|
|
695
|
+
const start = Math.min(this.invalidationJournalOffset, content.byteLength)
|
|
696
|
+
const recent = content.subarray(start).toString("utf-8")
|
|
697
|
+
const lastCompleteLine = recent.lastIndexOf("\n")
|
|
698
|
+
if (lastCompleteLine < 0) return false
|
|
699
|
+
this.invalidationJournalOffset = start + Buffer.byteLength(recent.slice(0, lastCompleteLine + 1))
|
|
700
|
+
let invalidated = false
|
|
701
|
+
for (const line of recent.slice(0, lastCompleteLine).split("\n")) {
|
|
702
|
+
if (!line.trim()) continue
|
|
703
|
+
let event: InvalidationEvent
|
|
704
|
+
try { event = JSON.parse(line) as InvalidationEvent } catch { invalidated = true; continue }
|
|
705
|
+
if (event.pid === process.pid) continue
|
|
706
|
+
invalidated = true
|
|
707
|
+
}
|
|
708
|
+
if (!invalidated) return false
|
|
709
|
+
|
|
710
|
+
this.toolResponses.clear()
|
|
711
|
+
this.analysisResults.clear()
|
|
712
|
+
this.graphCache.invalidateAll()
|
|
713
|
+
this.invalidation.version++
|
|
714
|
+
this.invalidationVersionOnWrite = this.invalidation.version
|
|
715
|
+
return true
|
|
716
|
+
} catch {
|
|
717
|
+
// A corrupt journal must never make the plugin unusable; force safe misses.
|
|
718
|
+
this.toolResponses.clear()
|
|
719
|
+
this.analysisResults.clear()
|
|
720
|
+
this.graphCache.invalidateAll()
|
|
721
|
+
return true
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
623
725
|
// ── Per-Type Graph Cache ──────────────────────────────────────────
|
|
624
726
|
|
|
625
727
|
/**
|
|
@@ -656,6 +758,7 @@ export class CacheManager {
|
|
|
656
758
|
}
|
|
657
759
|
this.invalidation.version++
|
|
658
760
|
this.saveInvalidationTracker()
|
|
761
|
+
this.appendInvalidationEvent({ nodeTypes: types, relTypes: [], full: false })
|
|
659
762
|
}
|
|
660
763
|
|
|
661
764
|
/**
|
|
@@ -875,7 +978,7 @@ export class CacheManager {
|
|
|
875
978
|
const path = join(this.projectDir, INVALIDATION_FILE)
|
|
876
979
|
const dir = dirname(path)
|
|
877
980
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
878
|
-
|
|
981
|
+
atomicWriteFile(path, JSON.stringify({
|
|
879
982
|
lastFullInvalidation: this.invalidation.lastFullInvalidation,
|
|
880
983
|
version: this.invalidation.version,
|
|
881
984
|
dirtyTypes: [...this.invalidation.dirtyTypes],
|
|
@@ -883,6 +986,46 @@ export class CacheManager {
|
|
|
883
986
|
dirtyRelTypes: [...this.invalidation.dirtyRelTypes],
|
|
884
987
|
}))
|
|
885
988
|
}
|
|
989
|
+
|
|
990
|
+
private appendInvalidationEvent(change: { nodeTypes: string[]; relTypes: string[]; full: boolean }): void {
|
|
991
|
+
const path = join(this.projectDir, INVALIDATION_JOURNAL_FILE)
|
|
992
|
+
const event: InvalidationEvent = {
|
|
993
|
+
id: `${Date.now()}-${process.pid}-${++this.invalidationEventCounter}`,
|
|
994
|
+
timestamp: Date.now(),
|
|
995
|
+
pid: process.pid,
|
|
996
|
+
...change,
|
|
997
|
+
}
|
|
998
|
+
try {
|
|
999
|
+
const directory = dirname(path)
|
|
1000
|
+
if (!existsSync(directory)) mkdirSync(directory, { recursive: true })
|
|
1001
|
+
if (existsSync(path) && statSync(path).size > INVALIDATION_JOURNAL_MAX_BYTES) {
|
|
1002
|
+
atomicWriteFile(path, `${JSON.stringify(event)}\n`)
|
|
1003
|
+
} else {
|
|
1004
|
+
const descriptor = openSync(path, "a", 0o600)
|
|
1005
|
+
try {
|
|
1006
|
+
writeFileSync(descriptor, `${JSON.stringify(event)}\n`, "utf-8")
|
|
1007
|
+
fsyncSync(descriptor)
|
|
1008
|
+
} finally {
|
|
1009
|
+
closeSync(descriptor)
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
this.invalidationJournalOffset = statSync(path).size
|
|
1013
|
+
} catch {}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
private toolNeedsSource(toolName: string): boolean {
|
|
1017
|
+
return toolName === "sdd.detect_drift" || toolName === "sdd.quality" || toolName === "sdd.coverage"
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
private analysisNeedsSource(type: string): boolean {
|
|
1021
|
+
return type === "drift" || type === "quality" || type === "coverage"
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
private getSourceFingerprint(): string {
|
|
1025
|
+
const current = sourceFingerprint(this.projectDir, this.sourceFingerprintCache)
|
|
1026
|
+
this.sourceFingerprintCache = current
|
|
1027
|
+
return current.fingerprint
|
|
1028
|
+
}
|
|
886
1029
|
}
|
|
887
1030
|
|
|
888
1031
|
// ── Singleton per project ────────────────────────────────────────────
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
* Solução G: Snapshot persistente do grafo.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { existsSync, readFileSync,
|
|
10
|
+
import { existsSync, readFileSync, mkdirSync } from "fs"
|
|
11
11
|
import { join, dirname } from "path"
|
|
12
12
|
import type { KnowledgeGraph, AnyNode, Relationship } from "../domain/types.js"
|
|
13
|
+
import { atomicWriteFile } from "./atomic.js"
|
|
14
|
+
import { graphFingerprint } from "./fingerprint.js"
|
|
13
15
|
|
|
14
16
|
const SNAPSHOT_FILE = ".sdd/graph-cache.json"
|
|
15
17
|
const SNAPSHOT_MAX_AGE_MS = 60 * 60 * 1000 // 1 hour
|
|
@@ -31,6 +33,7 @@ interface SerializedSnapshot {
|
|
|
31
33
|
graph: KnowledgeGraph
|
|
32
34
|
indices: SerializedIndices
|
|
33
35
|
graphHash: string
|
|
36
|
+
sourceSignature: string
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
export class GraphSnapshotStore {
|
|
@@ -43,27 +46,28 @@ export class GraphSnapshotStore {
|
|
|
43
46
|
/**
|
|
44
47
|
* Save full graph + indices to disk.
|
|
45
48
|
*/
|
|
46
|
-
save(graph: KnowledgeGraph, graphHash: string): void {
|
|
49
|
+
save(graph: KnowledgeGraph, graphHash: string, sourceSignature: string): void {
|
|
47
50
|
const snapshot: SerializedSnapshot = {
|
|
48
|
-
version:
|
|
51
|
+
version: 3,
|
|
49
52
|
timestamp: Date.now(),
|
|
50
53
|
graph,
|
|
51
54
|
indices: this.serializeIndices(graph),
|
|
52
55
|
graphHash,
|
|
56
|
+
sourceSignature,
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
const path = join(this.projectDir, SNAPSHOT_FILE)
|
|
56
60
|
const dir = dirname(path)
|
|
57
61
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
58
62
|
|
|
59
|
-
|
|
63
|
+
atomicWriteFile(path, JSON.stringify(snapshot))
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
/**
|
|
63
67
|
* Load graph from snapshot if valid.
|
|
64
68
|
* Returns null if snapshot is missing, stale, or corrupted.
|
|
65
69
|
*/
|
|
66
|
-
load(): { graph: KnowledgeGraph; graphHash: string } | null {
|
|
70
|
+
load(): { graph: KnowledgeGraph; graphHash: string; sourceSignature: string } | null {
|
|
67
71
|
const path = join(this.projectDir, SNAPSHOT_FILE)
|
|
68
72
|
if (!existsSync(path)) return null
|
|
69
73
|
|
|
@@ -72,17 +76,19 @@ export class GraphSnapshotStore {
|
|
|
72
76
|
const snapshot: SerializedSnapshot = JSON.parse(raw)
|
|
73
77
|
|
|
74
78
|
// Validate version
|
|
75
|
-
if (snapshot.version !==
|
|
79
|
+
if (snapshot.version !== 3) return null
|
|
76
80
|
|
|
77
81
|
// Validate age
|
|
78
82
|
if (Date.now() - snapshot.timestamp > SNAPSHOT_MAX_AGE_MS) return null
|
|
79
83
|
|
|
80
84
|
// Validate graph structure
|
|
81
85
|
if (!snapshot.graph || !Array.isArray(snapshot.graph.nodes)) return null
|
|
86
|
+
if (!snapshot.sourceSignature || snapshot.graphHash !== graphFingerprint(snapshot.graph)) return null
|
|
82
87
|
|
|
83
88
|
return {
|
|
84
89
|
graph: snapshot.graph,
|
|
85
90
|
graphHash: snapshot.graphHash || "",
|
|
91
|
+
sourceSignature: snapshot.sourceSignature,
|
|
86
92
|
}
|
|
87
93
|
} catch {
|
|
88
94
|
return null
|
|
@@ -99,7 +105,8 @@ export class GraphSnapshotStore {
|
|
|
99
105
|
try {
|
|
100
106
|
const raw = readFileSync(path, "utf-8")
|
|
101
107
|
const snapshot: SerializedSnapshot = JSON.parse(raw)
|
|
102
|
-
return snapshot.version ===
|
|
108
|
+
return snapshot.version === 3 && Boolean(snapshot.sourceSignature) &&
|
|
109
|
+
(Date.now() - snapshot.timestamp) < SNAPSHOT_MAX_AGE_MS
|
|
103
110
|
} catch {
|
|
104
111
|
return false
|
|
105
112
|
}
|