solidity-argus 0.3.6 → 0.5.6
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/AGENTS.md +13 -6
- package/README.md +24 -12
- package/package.json +7 -3
- package/skills/checklists/cyfrin-best-practices-runtime/SKILL.md +1 -0
- package/skills/checklists/cyfrin-best-practices-upgrades/SKILL.md +1 -0
- package/skills/checklists/cyfrin-defi-core/SKILL.md +1 -0
- package/skills/checklists/cyfrin-defi-integrations/SKILL.md +1 -0
- package/skills/checklists/cyfrin-gas/SKILL.md +1 -0
- package/skills/checklists/general-audit/SKILL.md +1 -0
- package/skills/methodology/audit-workflow/SKILL.md +1 -0
- package/skills/methodology/report-template/SKILL.md +1 -0
- package/skills/methodology/severity-classification/SKILL.md +1 -0
- package/skills/protocol-patterns/amm-dex/SKILL.md +1 -0
- package/skills/protocol-patterns/bridges-cross-chain/SKILL.md +1 -0
- package/skills/protocol-patterns/dao-governance/SKILL.md +1 -0
- package/skills/protocol-patterns/lending-borrowing/SKILL.md +1 -0
- package/skills/protocol-patterns/staking-vesting/SKILL.md +1 -0
- package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +0 -50
- package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +0 -63
- package/src/agents/argus-prompt.ts +98 -33
- package/src/agents/pythia-prompt.ts +18 -1
- package/src/agents/scribe-prompt.ts +32 -10
- package/src/agents/sentinel-prompt.ts +19 -0
- package/src/agents/themis-prompt.ts +110 -0
- package/src/cli/commands/doctor.ts +29 -17
- package/src/config/loader.ts +29 -5
- package/src/config/schema.ts +45 -45
- package/src/constants/defaults.ts +1 -0
- package/src/create-hooks.ts +851 -142
- package/src/create-managers.ts +4 -2
- package/src/create-tools.ts +5 -1
- package/src/features/audit-enforcer/audit-enforcer.ts +1 -11
- package/src/features/background-agent/background-manager.ts +32 -5
- package/src/features/error-recovery/tool-error-recovery.ts +1 -0
- package/src/features/persistent-state/audit-state-manager.ts +272 -29
- package/src/features/persistent-state/event-sink.ts +96 -25
- package/src/features/persistent-state/findings-materializer.ts +57 -3
- package/src/features/persistent-state/global-run-index.ts +86 -8
- package/src/features/persistent-state/index.ts +7 -1
- package/src/features/persistent-state/run-finalizer.ts +116 -7
- package/src/features/persistent-state/run-pruner.ts +93 -0
- package/src/hooks/agent-tracker.ts +14 -2
- package/src/hooks/compaction-hook.ts +7 -16
- package/src/hooks/config-handler.ts +83 -29
- package/src/hooks/context-budget.ts +4 -5
- package/src/hooks/event-hook.ts +213 -57
- package/src/hooks/knowledge-sync-hook.ts +2 -3
- package/src/hooks/safe-create-hook.ts +13 -1
- package/src/hooks/system-prompt-hook.ts +20 -39
- package/src/hooks/tool-tracking-hook.ts +606 -326
- package/src/index.ts +15 -1
- package/src/knowledge/scvd-client.ts +2 -4
- package/src/knowledge/scvd-errors.ts +25 -2
- package/src/knowledge/scvd-index.ts +7 -5
- package/src/knowledge/scvd-sync.ts +6 -6
- package/src/managers/types.ts +20 -2
- package/src/shared/agent-names.ts +23 -0
- package/src/shared/audit-artifact-resolver.ts +8 -3
- package/src/shared/audit-phases.ts +12 -0
- package/src/shared/cache-paths.ts +41 -0
- package/src/shared/drop-diagnostics.ts +2 -2
- package/src/shared/forge-errors.ts +31 -0
- package/src/shared/forge-runner.ts +30 -0
- package/src/shared/format-error.ts +3 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/key-tools.ts +39 -0
- package/src/shared/logger.ts +7 -7
- package/src/shared/path-containment.ts +25 -0
- package/src/shared/path-utils.ts +11 -0
- package/src/shared/report-path-resolver.ts +4 -2
- package/src/shared/safe-emit.ts +24 -0
- package/src/shared/token-utils.ts +5 -0
- package/src/shared/type-guards.ts +8 -0
- package/src/shared/validation-constants.ts +52 -0
- package/src/skills/analysis/cluster.ts +1 -114
- package/src/skills/analysis/normalize.ts +2 -114
- package/src/skills/analysis/stopwords.ts +109 -0
- package/src/skills/argus-skill-resolver.ts +6 -3
- package/src/solodit-lifecycle.ts +153 -37
- package/src/state/adapters.ts +60 -66
- package/src/state/finding-aggregation.ts +6 -8
- package/src/state/finding-fingerprint.ts +1 -1
- package/src/state/finding-store.ts +31 -9
- package/src/state/index.ts +1 -1
- package/src/state/projectors.ts +27 -19
- package/src/state/schemas.ts +8 -32
- package/src/state/types.ts +3 -0
- package/src/tools/contract-analyzer-tool.ts +4 -6
- package/src/tools/forge-coverage-tool.ts +10 -35
- package/src/tools/forge-fuzz-tool.ts +21 -51
- package/src/tools/forge-test-tool.ts +25 -47
- package/src/tools/gas-analysis-tool.ts +12 -41
- package/src/tools/pattern-checker-tool.ts +37 -15
- package/src/tools/pattern-loader.ts +18 -4
- package/src/tools/persist-deduped-tool.ts +94 -0
- package/src/tools/proxy-detection-tool.ts +35 -34
- package/src/tools/read-findings-tool.ts +390 -0
- package/src/tools/record-finding-tool.ts +120 -25
- package/src/tools/report-generator-tool.ts +396 -328
- package/src/tools/report-preflight.ts +5 -1
- package/src/tools/slither-tool.ts +55 -16
- package/src/tools/solodit-search-tool.ts +260 -112
- package/src/tools/sync-knowledge-tool.ts +2 -3
- package/src/utils/solidity-parser.ts +39 -24
- package/src/features/migration/index.ts +0 -14
- package/src/features/migration/migration-adapter.ts +0 -151
- package/src/features/migration/parity-telemetry.ts +0 -133
package/src/create-managers.ts
CHANGED
|
@@ -16,8 +16,10 @@ export function createManagers(args: {
|
|
|
16
16
|
const backgroundManager = createBackgroundManager(
|
|
17
17
|
backgroundDispatcher ??
|
|
18
18
|
(async (agentName: string, prompt: string) => {
|
|
19
|
-
logger.warn(
|
|
20
|
-
|
|
19
|
+
logger.warn(
|
|
20
|
+
`Background dispatcher not configured — task will not be executed: ${agentName} (${prompt.slice(0, 50)}...)`,
|
|
21
|
+
)
|
|
22
|
+
return ""
|
|
21
23
|
}),
|
|
22
24
|
{ maxConcurrent: config.background?.max_concurrent ?? 3 },
|
|
23
25
|
)
|
package/src/create-tools.ts
CHANGED
|
@@ -7,7 +7,9 @@ import { forgeFuzzTool } from "./tools/forge-fuzz-tool"
|
|
|
7
7
|
import { forgeTestTool } from "./tools/forge-test-tool"
|
|
8
8
|
import { gasAnalysisTool } from "./tools/gas-analysis-tool"
|
|
9
9
|
import { patternCheckerTool } from "./tools/pattern-checker-tool"
|
|
10
|
+
import { persistDedupedTool } from "./tools/persist-deduped-tool"
|
|
10
11
|
import { proxyDetectionTool } from "./tools/proxy-detection-tool"
|
|
12
|
+
import { readFindingsTool } from "./tools/read-findings-tool"
|
|
11
13
|
import { recordFindingTool } from "./tools/record-finding-tool"
|
|
12
14
|
import { reportGeneratorTool } from "./tools/report-generator-tool"
|
|
13
15
|
import { slitherTool } from "./tools/slither-tool"
|
|
@@ -26,12 +28,14 @@ export function createTools(config: ArgusConfig): Record<string, ToolDefinition>
|
|
|
26
28
|
argus_proxy_detection: proxyDetectionTool,
|
|
27
29
|
argus_skill_load: argusSkillLoadTool,
|
|
28
30
|
argus_record_finding: recordFindingTool,
|
|
31
|
+
argus_read_findings: readFindingsTool,
|
|
32
|
+
argus_persist_deduped: persistDedupedTool,
|
|
29
33
|
argus_generate_report: reportGeneratorTool,
|
|
30
34
|
argus_sync_knowledge: syncKnowledgeTool,
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
if (config.solodit?.enabled !== false) {
|
|
34
|
-
tools.argus_solodit_search = createSoloditSearchTool(
|
|
38
|
+
tools.argus_solodit_search = createSoloditSearchTool()
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
return tools
|
|
@@ -1,16 +1,6 @@
|
|
|
1
|
+
import { PHASE_ORDER } from "../../shared/audit-phases"
|
|
1
2
|
import type { AuditPhase, AuditState } from "../../state/types"
|
|
2
3
|
|
|
3
|
-
const PHASE_ORDER: AuditPhase[] = [
|
|
4
|
-
"reconnaissance",
|
|
5
|
-
"scanning",
|
|
6
|
-
"manual-review",
|
|
7
|
-
"attack-surface",
|
|
8
|
-
"research",
|
|
9
|
-
"testing",
|
|
10
|
-
"reporting",
|
|
11
|
-
"complete",
|
|
12
|
-
]
|
|
13
|
-
|
|
14
4
|
const REPORTING_PHASES: AuditPhase[] = ["reporting", "complete"]
|
|
15
5
|
|
|
16
6
|
const KEY_TOOL_FAMILIES: Array<{ family: string; prefixes: string[] }> = [
|
|
@@ -44,6 +44,7 @@ export function createBackgroundManager(
|
|
|
44
44
|
let runningCount = 0
|
|
45
45
|
const maxConcurrent = options?.maxConcurrent ?? 3
|
|
46
46
|
let taskCount = 0
|
|
47
|
+
let drainScheduled = false
|
|
47
48
|
|
|
48
49
|
function safeInvokeCallback(callback: CompletionCallback, taskId: string, result: unknown): void {
|
|
49
50
|
try {
|
|
@@ -70,7 +71,16 @@ export function createBackgroundManager(
|
|
|
70
71
|
task.callbacks.clear()
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
function
|
|
74
|
+
function scheduleDrain(): void {
|
|
75
|
+
if (drainScheduled) return
|
|
76
|
+
drainScheduled = true
|
|
77
|
+
queueMicrotask(() => {
|
|
78
|
+
drainScheduled = false
|
|
79
|
+
drainQueue()
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function drainQueue(): void {
|
|
74
84
|
while (runningCount < maxConcurrent && queue.length > 0) {
|
|
75
85
|
const nextTaskId = queue.shift()
|
|
76
86
|
|
|
@@ -86,7 +96,16 @@ export function createBackgroundManager(
|
|
|
86
96
|
task.status = "running"
|
|
87
97
|
runningCount += 1
|
|
88
98
|
|
|
89
|
-
|
|
99
|
+
const TASK_TIMEOUT_MS = 5 * 60 * 1000
|
|
100
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
|
|
101
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
102
|
+
timeoutHandle = setTimeout(
|
|
103
|
+
() => reject(new Error(`Background task timed out after 5 minutes: ${nextTaskId}`)),
|
|
104
|
+
TASK_TIMEOUT_MS,
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
Promise.race([dispatcher(task.agentName, task.prompt, task.options), timeoutPromise])
|
|
90
109
|
.then((result) => {
|
|
91
110
|
const currentTask = tasks.get(nextTaskId)
|
|
92
111
|
|
|
@@ -105,14 +124,22 @@ export function createBackgroundManager(
|
|
|
105
124
|
return
|
|
106
125
|
}
|
|
107
126
|
|
|
127
|
+
const isTimeout =
|
|
128
|
+
error instanceof Error && error.message.includes("timed out after 5 minutes")
|
|
129
|
+
if (isTimeout) {
|
|
130
|
+
logger.error(`Background task timed out: ${nextTaskId}`, error)
|
|
131
|
+
} else {
|
|
132
|
+
logger.error(`Background task failed: ${nextTaskId}`, error)
|
|
133
|
+
}
|
|
134
|
+
|
|
108
135
|
currentTask.status = "failed"
|
|
109
136
|
currentTask.error = error
|
|
110
|
-
logger.error(`Background task failed: ${nextTaskId}`, error)
|
|
111
137
|
invokeCallbacks(nextTaskId, error)
|
|
112
138
|
})
|
|
113
139
|
.finally(() => {
|
|
140
|
+
if (timeoutHandle) clearTimeout(timeoutHandle)
|
|
114
141
|
runningCount = Math.max(0, runningCount - 1)
|
|
115
|
-
|
|
142
|
+
scheduleDrain()
|
|
116
143
|
})
|
|
117
144
|
}
|
|
118
145
|
}
|
|
@@ -130,7 +157,7 @@ export function createBackgroundManager(
|
|
|
130
157
|
})
|
|
131
158
|
|
|
132
159
|
queue.push(taskId)
|
|
133
|
-
|
|
160
|
+
scheduleDrain()
|
|
134
161
|
|
|
135
162
|
return taskId
|
|
136
163
|
}
|
|
@@ -60,6 +60,7 @@ export function createToolErrorRecoveryHandler(
|
|
|
60
60
|
|
|
61
61
|
return (toolResult: { tool: string; result: string }): string | null => {
|
|
62
62
|
const { tool, result } = toolResult
|
|
63
|
+
if (!result || typeof result !== "string") return null
|
|
63
64
|
const lowerResult = result.toLowerCase()
|
|
64
65
|
|
|
65
66
|
const isViaIr =
|
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { mkdirSync } from "node:fs"
|
|
3
|
+
import { mkdir, readdir, rename, rm, stat } from "node:fs/promises"
|
|
2
4
|
import { dirname, join } from "node:path"
|
|
3
5
|
import type { AuditStateManager } from "../../managers/types"
|
|
4
6
|
import { createLogger } from "../../shared/logger"
|
|
5
7
|
import { type ArgusRootResolver, defaultRootResolver } from "../../shared/path-root-resolver"
|
|
6
8
|
import { createAuditState } from "../../state/audit-state"
|
|
9
|
+
import { normalizeText } from "../../state/finding-fingerprint"
|
|
7
10
|
import { projectAuditState, stableHash } from "../../state/projectors"
|
|
8
11
|
import type { AuditState, PersistentAuditState } from "../../state/types"
|
|
9
12
|
import { readEvents } from "./event-sink"
|
|
10
13
|
|
|
11
14
|
const STATE_FILE_NAME = "argus-state.json"
|
|
15
|
+
const SESSIONS_DIR = "sessions"
|
|
12
16
|
const STATE_VERSION = "2"
|
|
13
17
|
|
|
14
18
|
type ProjectedAuditCore = Pick<
|
|
@@ -24,6 +28,73 @@ interface ConsistentStateResult {
|
|
|
24
28
|
repaired: boolean
|
|
25
29
|
}
|
|
26
30
|
|
|
31
|
+
const SAVE_MUTEX_TIMEOUT_MS = 30_000
|
|
32
|
+
const MAX_SAVE_CAS_RETRIES = 10
|
|
33
|
+
const LEGACY_OBSERVATION_ID_PATTERN = /^obs-\d+$/
|
|
34
|
+
|
|
35
|
+
function generateDeterministicFindingId(
|
|
36
|
+
check: string,
|
|
37
|
+
file: string,
|
|
38
|
+
lines: [number, number],
|
|
39
|
+
): string {
|
|
40
|
+
return createHash("sha256")
|
|
41
|
+
.update(`${normalizeText(check)}:${normalizeText(file)}:${lines[0]}-${lines[1]}`)
|
|
42
|
+
.digest("hex")
|
|
43
|
+
.substring(0, 16)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function migrateLegacyFindingIds(state: AuditState): number {
|
|
47
|
+
let migratedCount = 0
|
|
48
|
+
|
|
49
|
+
state.findings = state.findings.map((finding) => {
|
|
50
|
+
if (!LEGACY_OBSERVATION_ID_PATTERN.test(finding.id)) {
|
|
51
|
+
return finding
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
migratedCount += 1
|
|
55
|
+
return {
|
|
56
|
+
...finding,
|
|
57
|
+
id: generateDeterministicFindingId(finding.check, finding.file, finding.lines),
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
return migratedCount
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createAsyncMutex(timeoutMs = SAVE_MUTEX_TIMEOUT_MS) {
|
|
65
|
+
const logger = createLogger()
|
|
66
|
+
let chain = Promise.resolve()
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
async acquire(): Promise<() => void> {
|
|
70
|
+
const previous = chain
|
|
71
|
+
let releaseCurrent!: () => void
|
|
72
|
+
chain = new Promise<void>((resolve) => {
|
|
73
|
+
releaseCurrent = resolve
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
await previous
|
|
77
|
+
|
|
78
|
+
let released = false
|
|
79
|
+
const timeout = setTimeout(() => {
|
|
80
|
+
// Log the timeout but do NOT release — the holder must finish
|
|
81
|
+
// its critical section and call release() explicitly.
|
|
82
|
+
logger.error(`audit-state-manager mutex held for >${timeoutMs}ms — possible deadlock`)
|
|
83
|
+
}, timeoutMs)
|
|
84
|
+
|
|
85
|
+
return () => {
|
|
86
|
+
if (released) {
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
released = true
|
|
91
|
+
clearTimeout(timeout)
|
|
92
|
+
releaseCurrent()
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
27
98
|
function isObject(value: unknown): value is Record<string, unknown> {
|
|
28
99
|
return typeof value === "object" && value !== null
|
|
29
100
|
}
|
|
@@ -115,28 +186,40 @@ export function createDebouncedSave(
|
|
|
115
186
|
): {
|
|
116
187
|
save: (state: AuditState) => void
|
|
117
188
|
flush: () => Promise<void>
|
|
189
|
+
dispose: () => void
|
|
118
190
|
} {
|
|
119
191
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
120
|
-
|
|
192
|
+
const pendingStates: AuditState[] = []
|
|
193
|
+
let persistQueue = Promise.resolve()
|
|
121
194
|
|
|
122
|
-
async function
|
|
123
|
-
if (
|
|
195
|
+
async function persistPendingStateQueue(): Promise<void> {
|
|
196
|
+
if (pendingStates.length === 0) {
|
|
124
197
|
return
|
|
125
198
|
}
|
|
126
199
|
|
|
127
|
-
|
|
128
|
-
|
|
200
|
+
// Only the latest state matters — each write replaces the file
|
|
201
|
+
const latestState = pendingStates[pendingStates.length - 1]
|
|
202
|
+
if (!latestState) {
|
|
203
|
+
pendingStates.length = 0
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
pendingStates.length = 0
|
|
129
207
|
|
|
130
208
|
try {
|
|
131
|
-
await saveState(
|
|
209
|
+
await saveState(latestState)
|
|
132
210
|
} catch {
|
|
133
211
|
createLogger().debug("Debounced state persistence failed")
|
|
134
212
|
}
|
|
135
213
|
}
|
|
136
214
|
|
|
215
|
+
function enqueuePersist(): Promise<void> {
|
|
216
|
+
persistQueue = persistQueue.then(() => persistPendingStateQueue())
|
|
217
|
+
return persistQueue
|
|
218
|
+
}
|
|
219
|
+
|
|
137
220
|
return {
|
|
138
221
|
save(state: AuditState): void {
|
|
139
|
-
|
|
222
|
+
pendingStates.push(state)
|
|
140
223
|
|
|
141
224
|
if (timer) {
|
|
142
225
|
clearTimeout(timer)
|
|
@@ -144,7 +227,7 @@ export function createDebouncedSave(
|
|
|
144
227
|
|
|
145
228
|
timer = setTimeout(() => {
|
|
146
229
|
timer = null
|
|
147
|
-
void
|
|
230
|
+
void enqueuePersist()
|
|
148
231
|
}, delayMs)
|
|
149
232
|
},
|
|
150
233
|
async flush(): Promise<void> {
|
|
@@ -153,7 +236,14 @@ export function createDebouncedSave(
|
|
|
153
236
|
timer = null
|
|
154
237
|
}
|
|
155
238
|
|
|
156
|
-
await
|
|
239
|
+
await enqueuePersist()
|
|
240
|
+
},
|
|
241
|
+
dispose(): void {
|
|
242
|
+
if (timer) {
|
|
243
|
+
clearTimeout(timer)
|
|
244
|
+
timer = null
|
|
245
|
+
}
|
|
246
|
+
pendingStates.length = 0
|
|
157
247
|
},
|
|
158
248
|
}
|
|
159
249
|
}
|
|
@@ -164,8 +254,39 @@ export function createAuditStateManager(
|
|
|
164
254
|
): AuditStateManager {
|
|
165
255
|
const logger = createLogger()
|
|
166
256
|
|
|
167
|
-
const
|
|
257
|
+
const argusRoot = resolver.writeRoot(projectDir)
|
|
258
|
+
const sharedStateFilePath = join(argusRoot, STATE_FILE_NAME)
|
|
259
|
+
const sessionsDirPath = join(argusRoot, SESSIONS_DIR)
|
|
260
|
+
let stateFilePath = sharedStateFilePath
|
|
261
|
+
let boundSessionId: string | undefined
|
|
168
262
|
let currentState: AuditState = createAuditState(projectDir).state
|
|
263
|
+
const saveMutex = createAsyncMutex()
|
|
264
|
+
|
|
265
|
+
async function cleanupStaleTempFiles(): Promise<void> {
|
|
266
|
+
for (const dirPath of [argusRoot, sessionsDirPath]) {
|
|
267
|
+
let entries: string[]
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
entries = await readdir(dirPath)
|
|
271
|
+
} catch {
|
|
272
|
+
continue
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (const entry of entries) {
|
|
276
|
+
if (!entry.endsWith(".tmp")) {
|
|
277
|
+
continue
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
await rm(join(dirPath, entry), { force: true })
|
|
282
|
+
} catch (error) {
|
|
283
|
+
logger.warn(`Failed to remove stale tmp state file ${entry}`, error)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const startupCleanup = cleanupStaleTempFiles()
|
|
169
290
|
|
|
170
291
|
async function deriveConsistentState(state: AuditState): Promise<ConsistentStateResult> {
|
|
171
292
|
if (!state.sessionId || !state.projectDir) {
|
|
@@ -219,16 +340,91 @@ export function createAuditStateManager(
|
|
|
219
340
|
}
|
|
220
341
|
}
|
|
221
342
|
|
|
343
|
+
function bindSession(sessionId: string): void {
|
|
344
|
+
if (boundSessionId) {
|
|
345
|
+
logger.debug(`Already bound to session ${boundSessionId}, ignoring bind for ${sessionId}`)
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
boundSessionId = sessionId
|
|
349
|
+
stateFilePath = join(sessionsDirPath, `state-${sessionId}.json`)
|
|
350
|
+
try {
|
|
351
|
+
mkdirSync(sessionsDirPath, { recursive: true })
|
|
352
|
+
} catch {
|
|
353
|
+
logger.warn(`Failed to create sessions directory: ${sessionsDirPath}`)
|
|
354
|
+
}
|
|
355
|
+
logger.debug(`Bound state manager to session ${sessionId}: ${stateFilePath}`)
|
|
356
|
+
}
|
|
357
|
+
|
|
222
358
|
async function load(): Promise<AuditState | null> {
|
|
223
359
|
try {
|
|
224
|
-
|
|
225
|
-
|
|
360
|
+
// 1. If bound to a session, try the session-scoped file first
|
|
361
|
+
let readPath: string | null = null
|
|
226
362
|
|
|
227
|
-
|
|
228
|
-
|
|
363
|
+
if (boundSessionId) {
|
|
364
|
+
const sessionFile = Bun.file(stateFilePath)
|
|
365
|
+
if (await sessionFile.exists()) {
|
|
366
|
+
readPath = stateFilePath
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// 2. Bound sessions with no matching file start clean — no cross-session contamination
|
|
371
|
+
if (!readPath && boundSessionId) {
|
|
372
|
+
logger.info("Starting new audit session with clean state")
|
|
373
|
+
return null
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// 3. Unbound: scan sessions dir for most recent (backward compat)
|
|
377
|
+
if (!readPath) {
|
|
378
|
+
try {
|
|
379
|
+
const entries = await readdir(sessionsDirPath)
|
|
380
|
+
const jsonFiles = entries.filter((e) => e.startsWith("state-") && e.endsWith(".json"))
|
|
381
|
+
|
|
382
|
+
if (jsonFiles.length > 0) {
|
|
383
|
+
let newest: { name: string; mtime: number } | null = null
|
|
384
|
+
for (const name of jsonFiles) {
|
|
385
|
+
const filePath = join(sessionsDirPath, name)
|
|
386
|
+
try {
|
|
387
|
+
const s = await stat(filePath)
|
|
388
|
+
const mtime = s.mtimeMs
|
|
389
|
+
if (
|
|
390
|
+
!newest ||
|
|
391
|
+
mtime > newest.mtime ||
|
|
392
|
+
(mtime === newest.mtime && name > newest.name)
|
|
393
|
+
) {
|
|
394
|
+
newest = { name, mtime }
|
|
395
|
+
}
|
|
396
|
+
} catch {
|
|
397
|
+
// Skip unreadable files
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (newest) {
|
|
401
|
+
readPath = join(sessionsDirPath, newest.name)
|
|
402
|
+
logger.debug(
|
|
403
|
+
`No session-scoped file for (unbound), falling back to newest: ${newest.name}`,
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
} catch {
|
|
408
|
+
// sessions dir doesn't exist yet
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// 4. Unbound: try legacy shared file
|
|
413
|
+
if (!readPath) {
|
|
414
|
+
const resolvedPath = resolver.resolveReadPath(projectDir, STATE_FILE_NAME)
|
|
415
|
+
const legacyPath = resolvedPath ?? sharedStateFilePath
|
|
416
|
+
const legacyFile = Bun.file(legacyPath)
|
|
417
|
+
if (await legacyFile.exists()) {
|
|
418
|
+
readPath = legacyPath
|
|
419
|
+
logger.debug(`Falling back to legacy shared state file: ${legacyPath}`)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (!readPath) {
|
|
229
424
|
return null
|
|
230
425
|
}
|
|
231
426
|
|
|
427
|
+
const file = Bun.file(readPath)
|
|
232
428
|
const content = await file.text()
|
|
233
429
|
if (!content.trim()) {
|
|
234
430
|
return null
|
|
@@ -259,6 +455,11 @@ export function createAuditStateManager(
|
|
|
259
455
|
}
|
|
260
456
|
}
|
|
261
457
|
|
|
458
|
+
const migratedFindingCount = migrateLegacyFindingIds(state)
|
|
459
|
+
if (migratedFindingCount > 0) {
|
|
460
|
+
logger.info(`Migrating ${migratedFindingCount} finding IDs to deterministic format`)
|
|
461
|
+
}
|
|
462
|
+
|
|
262
463
|
if (snapshotSeq !== undefined) {
|
|
263
464
|
logger.debug(`Loaded snapshot with last_event_seq=${snapshotSeq} from ${readPath}`)
|
|
264
465
|
}
|
|
@@ -292,21 +493,18 @@ export function createAuditStateManager(
|
|
|
292
493
|
}
|
|
293
494
|
}
|
|
294
495
|
|
|
295
|
-
let saveInFlight = false
|
|
296
|
-
|
|
297
496
|
async function save(state: AuditState): Promise<void> {
|
|
497
|
+
await startupCleanup
|
|
498
|
+
const releaseMutex = await saveMutex.acquire()
|
|
298
499
|
currentState = state
|
|
299
500
|
|
|
300
|
-
if (saveInFlight) return
|
|
301
|
-
saveInFlight = true
|
|
302
|
-
|
|
303
501
|
try {
|
|
304
|
-
|
|
502
|
+
for (let attempt = 0; attempt < MAX_SAVE_CAS_RETRIES; attempt += 1) {
|
|
305
503
|
const stateToSave = currentState
|
|
306
504
|
const consistent = await deriveConsistentState(stateToSave)
|
|
307
505
|
|
|
308
506
|
if (consistent.repaired) {
|
|
309
|
-
logger.
|
|
507
|
+
logger.debug(
|
|
310
508
|
`State/core divergence detected for run ${stateToSave.sessionId}; auto-repairing`,
|
|
311
509
|
)
|
|
312
510
|
currentState = consistent.state
|
|
@@ -323,17 +521,40 @@ export function createAuditStateManager(
|
|
|
323
521
|
}
|
|
324
522
|
|
|
325
523
|
const tempFilePath = `${stateFilePath}.${Date.now()}.tmp`
|
|
326
|
-
|
|
327
|
-
await
|
|
328
|
-
|
|
524
|
+
const targetDir = dirname(stateFilePath)
|
|
525
|
+
await mkdir(targetDir, { recursive: true })
|
|
526
|
+
|
|
527
|
+
// Retry write+rename on ENOENT — mkdir may not have flushed to
|
|
528
|
+
// disk before Bun.write attempts to use the directory.
|
|
529
|
+
const ENOENT_RETRIES = 3
|
|
530
|
+
for (let fsRetry = 0; fsRetry < ENOENT_RETRIES; fsRetry += 1) {
|
|
531
|
+
try {
|
|
532
|
+
await Bun.write(tempFilePath, `${JSON.stringify(persistentState, null, 2)}\n`)
|
|
533
|
+
await rename(tempFilePath, stateFilePath)
|
|
534
|
+
break
|
|
535
|
+
} catch (fsErr) {
|
|
536
|
+
const isEnoent =
|
|
537
|
+
fsErr instanceof Error && (fsErr as NodeJS.ErrnoException).code === "ENOENT"
|
|
538
|
+
if (!isEnoent || fsRetry === ENOENT_RETRIES - 1) {
|
|
539
|
+
throw fsErr
|
|
540
|
+
}
|
|
541
|
+
// Re-create directory and retry after a brief delay
|
|
542
|
+
await mkdir(targetDir, { recursive: true })
|
|
543
|
+
await Bun.sleep(50)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
329
546
|
|
|
330
|
-
if (currentState === consistent.state)
|
|
547
|
+
if (currentState === consistent.state) {
|
|
548
|
+
return
|
|
549
|
+
}
|
|
331
550
|
}
|
|
551
|
+
|
|
552
|
+
logger.warn("CAS retries exhausted after 10 attempts; using last read state")
|
|
332
553
|
} catch (err) {
|
|
333
554
|
logger.warn("Failed to persist audit state", err)
|
|
334
555
|
throw err
|
|
335
556
|
} finally {
|
|
336
|
-
|
|
557
|
+
releaseMutex()
|
|
337
558
|
}
|
|
338
559
|
}
|
|
339
560
|
|
|
@@ -364,7 +585,7 @@ export function createAuditStateManager(
|
|
|
364
585
|
if (hasContent) {
|
|
365
586
|
try {
|
|
366
587
|
const consistent = await deriveConsistentState(currentState)
|
|
367
|
-
const archivesDir = join(
|
|
588
|
+
const archivesDir = join(argusRoot, "archives")
|
|
368
589
|
await mkdir(archivesDir, { recursive: true })
|
|
369
590
|
const archivePath = join(archivesDir, `argus-state.${Date.now()}.json`)
|
|
370
591
|
const persistentState: PersistentAuditState = {
|
|
@@ -383,15 +604,37 @@ export function createAuditStateManager(
|
|
|
383
604
|
}
|
|
384
605
|
|
|
385
606
|
currentState = createAuditState(projectDir).state
|
|
386
|
-
|
|
607
|
+
|
|
608
|
+
try {
|
|
609
|
+
await rm(stateFilePath, { force: true })
|
|
610
|
+
} catch (error) {
|
|
611
|
+
logger.warn(`Failed to remove live state file after archive: ${stateFilePath}`, error)
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
let disposed = false
|
|
616
|
+
|
|
617
|
+
async function dispose(): Promise<void> {
|
|
618
|
+
if (disposed) {
|
|
619
|
+
return
|
|
620
|
+
}
|
|
621
|
+
disposed = true
|
|
622
|
+
|
|
623
|
+
try {
|
|
624
|
+
await save(currentState)
|
|
625
|
+
} catch (err) {
|
|
626
|
+
logger.warn("Failed to flush state during dispose", err)
|
|
627
|
+
}
|
|
387
628
|
}
|
|
388
629
|
|
|
389
630
|
return {
|
|
631
|
+
bindSession,
|
|
390
632
|
load,
|
|
391
633
|
save,
|
|
392
634
|
get,
|
|
393
635
|
update,
|
|
394
636
|
reset,
|
|
395
637
|
archive,
|
|
638
|
+
dispose,
|
|
396
639
|
}
|
|
397
640
|
}
|