opencode-telos 1.0.1 → 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/opencode/hooks.d.ts.map +1 -1
- package/dist/opencode/hooks.js +0 -6
- 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/opencode/hooks.ts +0 -5
- 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/opencode/tools.ts
CHANGED
|
@@ -71,6 +71,7 @@ import {
|
|
|
71
71
|
createDriftWhitelistTool,
|
|
72
72
|
} from "./router/tools-composite.js"
|
|
73
73
|
import { createWorkflowTools } from "./workflows/tools-workflow.js"
|
|
74
|
+
import { graphFingerprint } from "../sdd/cache/fingerprint.js"
|
|
74
75
|
|
|
75
76
|
// ── Validation Coverage Index (singleton per session) ──────────────
|
|
76
77
|
const validationIndex = new ValidationIndex()
|
|
@@ -95,7 +96,7 @@ function getCachedToolResponse(directory: string, toolName: string, args: Record
|
|
|
95
96
|
const repo = getRepo(directory)
|
|
96
97
|
if (!repo.isInitialized()) return null
|
|
97
98
|
const graph = repo.loadGraph()
|
|
98
|
-
return cacheMgr.getToolResponse(toolName, args, graph
|
|
99
|
+
return cacheMgr.getToolResponse(toolName, args, graphFingerprint(graph))
|
|
99
100
|
} catch {
|
|
100
101
|
return null
|
|
101
102
|
}
|
|
@@ -110,7 +111,7 @@ function setCachedToolResponse(directory: string, toolName: string, args: Record
|
|
|
110
111
|
const repo = getRepo(directory)
|
|
111
112
|
if (!repo.isInitialized()) return
|
|
112
113
|
const graph = repo.loadGraph()
|
|
113
|
-
cacheMgr.setToolResponse(toolName, args, response, graph
|
|
114
|
+
cacheMgr.setToolResponse(toolName, args, response, graphFingerprint(graph))
|
|
114
115
|
} catch {}
|
|
115
116
|
}
|
|
116
117
|
|
|
@@ -698,7 +699,7 @@ export function createSddTools(): Record<string, ToolDefinition> {
|
|
|
698
699
|
|
|
699
700
|
// Check analysis cache
|
|
700
701
|
const cacheMgr = getCacheManager(ctx.directory)
|
|
701
|
-
|
|
702
|
+
const cached = cacheMgr.getAnalysisResult("validate", graphFingerprint(graph))
|
|
702
703
|
if (cached) {
|
|
703
704
|
recordTelemetry(ctx.directory, {
|
|
704
705
|
name: "graph_validation",
|
|
@@ -727,7 +728,7 @@ export function createSddTools(): Record<string, ToolDefinition> {
|
|
|
727
728
|
}
|
|
728
729
|
|
|
729
730
|
// Cache the result
|
|
730
|
-
|
|
731
|
+
cacheMgr.setAnalysisResult("validate", formatted, graphFingerprint(graph))
|
|
731
732
|
recordTelemetry(ctx.directory, {
|
|
732
733
|
name: "graph_validation",
|
|
733
734
|
duration_ms: Date.now() - startedAt,
|
|
@@ -751,14 +752,14 @@ export function createSddTools(): Record<string, ToolDefinition> {
|
|
|
751
752
|
|
|
752
753
|
// Check analysis cache
|
|
753
754
|
const cacheMgr = getCacheManager(ctx.directory)
|
|
754
|
-
|
|
755
|
+
const cached = cacheMgr.getAnalysisResult("drift", graphFingerprint(graph))
|
|
755
756
|
if (cached) return cached as string
|
|
756
757
|
|
|
757
758
|
const result = detectDrift(graph, ctx.directory)
|
|
758
759
|
const formatted = formatDriftReport(result)
|
|
759
760
|
|
|
760
761
|
// Cache the result
|
|
761
|
-
|
|
762
|
+
cacheMgr.setAnalysisResult("drift", formatted, graphFingerprint(graph))
|
|
762
763
|
|
|
763
764
|
return formatted
|
|
764
765
|
},
|
|
@@ -1867,14 +1868,14 @@ export function createSddTools(): Record<string, ToolDefinition> {
|
|
|
1867
1868
|
|
|
1868
1869
|
// Check analysis cache
|
|
1869
1870
|
const cacheMgr = getCacheManager(ctx.directory)
|
|
1870
|
-
|
|
1871
|
+
const cached = cacheMgr.getAnalysisResult("quality", graphFingerprint(graph))
|
|
1871
1872
|
if (cached) return cached as string
|
|
1872
1873
|
|
|
1873
1874
|
const report = calculateQualityScore(graph, ctx.directory)
|
|
1874
1875
|
const formatted = formatQualityReport(report)
|
|
1875
1876
|
|
|
1876
1877
|
// Cache the result
|
|
1877
|
-
|
|
1878
|
+
cacheMgr.setAnalysisResult("quality", formatted, graphFingerprint(graph))
|
|
1878
1879
|
|
|
1879
1880
|
return formatted
|
|
1880
1881
|
},
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, renameSync, writeFileSync } from "fs"
|
|
2
|
+
import { dirname } from "path"
|
|
3
|
+
|
|
4
|
+
/** Write a cache artifact so readers see either the old complete file or the new complete file. */
|
|
5
|
+
export function atomicWriteFile(filePath: string, content: string): void {
|
|
6
|
+
const directory = dirname(filePath)
|
|
7
|
+
if (!existsSync(directory)) mkdirSync(directory, { recursive: true })
|
|
8
|
+
|
|
9
|
+
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}`
|
|
10
|
+
let descriptor: number | undefined
|
|
11
|
+
try {
|
|
12
|
+
descriptor = openSync(temporaryPath, "w", 0o600)
|
|
13
|
+
writeFileSync(descriptor, content, "utf-8")
|
|
14
|
+
fsyncSync(descriptor)
|
|
15
|
+
closeSync(descriptor)
|
|
16
|
+
descriptor = undefined
|
|
17
|
+
renameSync(temporaryPath, filePath)
|
|
18
|
+
} finally {
|
|
19
|
+
if (descriptor !== undefined) {
|
|
20
|
+
try { closeSync(descriptor) } catch {}
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
// A failed rename must not leave a misleading cache artifact behind.
|
|
24
|
+
if (existsSync(temporaryPath)) {
|
|
25
|
+
const { unlinkSync } = require("fs")
|
|
26
|
+
unlinkSync(temporaryPath)
|
|
27
|
+
}
|
|
28
|
+
} catch {}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createHash } from "crypto"
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "fs"
|
|
3
|
+
import { join, relative } from "path"
|
|
4
|
+
import type { KnowledgeGraph } from "../domain/types.js"
|
|
5
|
+
|
|
6
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
7
|
+
".c", ".cc", ".cpp", ".cs", ".go", ".h", ".hpp", ".java", ".js", ".jsx",
|
|
8
|
+
".json", ".kt", ".mjs", ".mts", ".py", ".rb", ".rs", ".svelte", ".swift",
|
|
9
|
+
".ts", ".tsx", ".vue", ".yaml", ".yml",
|
|
10
|
+
])
|
|
11
|
+
|
|
12
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
13
|
+
".git", ".sdd", ".opencode", "node_modules", "dist", "build", "coverage",
|
|
14
|
+
".cache", ".next", ".turbo", "target", "vendor",
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
export function stableSerialize(value: unknown): string {
|
|
18
|
+
if (value === null) return "null"
|
|
19
|
+
if (value === undefined) return "undefined"
|
|
20
|
+
if (typeof value !== "object") return JSON.stringify(value)
|
|
21
|
+
if (Array.isArray(value)) return `[${value.map(stableSerialize).join(",")}]`
|
|
22
|
+
|
|
23
|
+
const record = value as Record<string, unknown>
|
|
24
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`).join(",")}}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function sha256(value: string | Uint8Array): string {
|
|
28
|
+
return createHash("sha256").update(value).digest("hex")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Deterministic content fingerprint for the complete graph, independent of YAML/SQLite formatting. */
|
|
32
|
+
export function graphFingerprint(graph: KnowledgeGraph): string {
|
|
33
|
+
return sha256(stableSerialize({
|
|
34
|
+
version: graph.version,
|
|
35
|
+
project_id: graph.project_id,
|
|
36
|
+
metadata: graph.metadata,
|
|
37
|
+
nodes: [...graph.nodes].sort((a, b) => a.id.localeCompare(b.id)),
|
|
38
|
+
relationships: [...graph.relationships].sort((a, b) => a.id.localeCompare(b.id)),
|
|
39
|
+
}))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function fileContentFingerprint(filePath: string): string {
|
|
43
|
+
try {
|
|
44
|
+
return sha256(readFileSync(filePath))
|
|
45
|
+
} catch {
|
|
46
|
+
return sha256(`missing:${filePath}`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Fast identity for external-file detection; includes all files when several files are supplied. */
|
|
51
|
+
export function fileSignature(filePaths: string[]): string {
|
|
52
|
+
const parts = filePaths.map((filePath) => {
|
|
53
|
+
try {
|
|
54
|
+
const stat = statSync(filePath)
|
|
55
|
+
return `${filePath}:${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`
|
|
56
|
+
} catch {
|
|
57
|
+
return `${filePath}:missing`
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
return sha256(parts.sort().join("\n"))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function configFingerprint(projectDir: string): string {
|
|
64
|
+
const path = join(projectDir, ".sdd", "config.json")
|
|
65
|
+
return existsSync(path) ? fileContentFingerprint(path) : sha256("missing-config")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sourceFiles(projectDir: string): string[] {
|
|
69
|
+
const files: string[] = []
|
|
70
|
+
const visit = (directory: string): void => {
|
|
71
|
+
let entries
|
|
72
|
+
try { entries = readdirSync(directory, { withFileTypes: true }) } catch { return }
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
if (entry.isSymbolicLink()) continue
|
|
75
|
+
const path = join(directory, entry.name)
|
|
76
|
+
if (entry.isDirectory()) {
|
|
77
|
+
if (!IGNORED_DIRECTORIES.has(entry.name)) visit(path)
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
const extension = entry.name.slice(entry.name.lastIndexOf(".")).toLowerCase()
|
|
81
|
+
if (SOURCE_EXTENSIONS.has(extension)) files.push(path)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
visit(projectDir)
|
|
85
|
+
return files.sort()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Content fingerprint for code-dependent analyses. The caller can cache this by fileSignature. */
|
|
89
|
+
export function sourceFingerprint(
|
|
90
|
+
projectDir: string,
|
|
91
|
+
previous?: { fingerprint: string; signature: string } | null,
|
|
92
|
+
): { fingerprint: string; signature: string } {
|
|
93
|
+
const files = sourceFiles(projectDir)
|
|
94
|
+
const signature = fileSignature(files)
|
|
95
|
+
if (previous?.signature === signature) return previous
|
|
96
|
+
const content = files.map((path) => `${relative(projectDir, path)}:${fileContentFingerprint(path)}`).join("\n")
|
|
97
|
+
return { fingerprint: sha256(content), signature }
|
|
98
|
+
}
|
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 ────────────────────────────────────────────
|