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
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { appendFile, mkdir } from "node:fs/promises"
|
|
2
3
|
import { dirname, join } from "node:path"
|
|
4
|
+
import { createLogger, type Logger } from "../../shared/logger"
|
|
3
5
|
import { type ArgusRootResolver, defaultRootResolver } from "../../shared/path-root-resolver"
|
|
4
6
|
import type { AuditEvent, AuditEventType } from "../../state/schemas"
|
|
5
7
|
|
|
6
|
-
export type EventSinkErrorCode = "
|
|
8
|
+
export type EventSinkErrorCode = "INVALID_EVENT" | "IO_ERROR"
|
|
7
9
|
|
|
8
10
|
export class EventSinkError extends Error {
|
|
9
11
|
readonly code: EventSinkErrorCode
|
|
@@ -16,8 +18,13 @@ export class EventSinkError extends Error {
|
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export interface EventSink {
|
|
21
|
+
readonly runId: string
|
|
22
|
+
/** Whether this sink has been marked as finalized. Post-finalization appends are silently dropped. */
|
|
23
|
+
readonly isFinalized: boolean
|
|
19
24
|
append(event: AuditEvent): Promise<void>
|
|
20
25
|
readAll(): Promise<AuditEvent[]>
|
|
26
|
+
/** Mark this sink as finalized. Subsequent appends (except run.finalized) are silently dropped. */
|
|
27
|
+
markFinalized(): void
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
const VALID_EVENT_TYPES: ReadonlySet<string> = new Set<AuditEventType>([
|
|
@@ -31,7 +38,15 @@ const VALID_EVENT_TYPES: ReadonlySet<string> = new Set<AuditEventType>([
|
|
|
31
38
|
"run.finalized",
|
|
32
39
|
])
|
|
33
40
|
|
|
34
|
-
|
|
41
|
+
export const MUTEX_TIMEOUT_MS = 30_000
|
|
42
|
+
|
|
43
|
+
export interface MutexOptions {
|
|
44
|
+
timeoutMs?: number
|
|
45
|
+
logger?: Logger
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createMutex(options: MutexOptions = {}) {
|
|
49
|
+
const { timeoutMs = MUTEX_TIMEOUT_MS, logger } = options
|
|
35
50
|
let chain = Promise.resolve()
|
|
36
51
|
|
|
37
52
|
return {
|
|
@@ -42,8 +57,14 @@ function createMutex() {
|
|
|
42
57
|
release = r
|
|
43
58
|
})
|
|
44
59
|
|
|
60
|
+
const timer = setTimeout(() => {
|
|
61
|
+
logger?.error("EventSink mutex held >30s — possible deadlock, still waiting")
|
|
62
|
+
}, timeoutMs)
|
|
63
|
+
|
|
45
64
|
await prev
|
|
46
65
|
|
|
66
|
+
clearTimeout(timer)
|
|
67
|
+
|
|
47
68
|
try {
|
|
48
69
|
return await fn()
|
|
49
70
|
} finally {
|
|
@@ -79,7 +100,22 @@ function parseJournalLines(content: string): AuditEvent[] {
|
|
|
79
100
|
}
|
|
80
101
|
}
|
|
81
102
|
|
|
82
|
-
|
|
103
|
+
// Canonical ordering: sort by timestamp (primary), written seq hint (secondary tiebreaker).
|
|
104
|
+
// This produces a stable, deterministic order even when multiple writers assign
|
|
105
|
+
// overlapping seq values — the written seq is a best-effort hint, not authoritative.
|
|
106
|
+
events.sort((a, b) => {
|
|
107
|
+
const tsDiff = a.timestamp - b.timestamp
|
|
108
|
+
if (tsDiff !== 0) return tsDiff
|
|
109
|
+
return a.seq - b.seq
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// Assign canonical sequential seq numbers starting from 1.
|
|
113
|
+
// All downstream consumers see clean, gap-free sequences regardless of
|
|
114
|
+
// how many independent writers appended to the journal.
|
|
115
|
+
for (let i = 0; i < events.length; i++) {
|
|
116
|
+
;(events[i] as AuditEvent).seq = i + 1
|
|
117
|
+
}
|
|
118
|
+
|
|
83
119
|
return events
|
|
84
120
|
}
|
|
85
121
|
|
|
@@ -96,19 +132,39 @@ export async function readEvents(
|
|
|
96
132
|
return parseJournalLines(content)
|
|
97
133
|
}
|
|
98
134
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
135
|
+
const sinkRegistry = new Map<string, EventSink>()
|
|
136
|
+
export function releaseEventSink(runId: string): void {
|
|
137
|
+
sinkRegistry.delete(runId)
|
|
138
|
+
}
|
|
139
|
+
export function resetSinkRegistry(): void {
|
|
140
|
+
sinkRegistry.clear()
|
|
141
|
+
}
|
|
142
|
+
|
|
103
143
|
export function createEventSink(
|
|
104
144
|
runId: string,
|
|
105
145
|
projectDir: string,
|
|
106
146
|
resolver: ArgusRootResolver = defaultRootResolver,
|
|
107
147
|
): EventSink {
|
|
148
|
+
const existing = sinkRegistry.get(runId)
|
|
149
|
+
if (existing) {
|
|
150
|
+
return existing
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const logger = createLogger()
|
|
108
154
|
const journalPath = buildJournalPath(runId, projectDir, resolver)
|
|
109
|
-
const
|
|
155
|
+
const markerPath = `${journalPath}.finalized`
|
|
156
|
+
const mutex = createMutex({ logger })
|
|
110
157
|
let lastSeq = 0
|
|
111
158
|
let initialized = false
|
|
159
|
+
const sinkState = { finalized: false }
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
if (existsSync(markerPath)) {
|
|
163
|
+
sinkState.finalized = true
|
|
164
|
+
}
|
|
165
|
+
} catch (err) {
|
|
166
|
+
logger.warn(`Failed to check finalization marker: ${String(err)}`)
|
|
167
|
+
}
|
|
112
168
|
|
|
113
169
|
async function ensureInitialized(): Promise<void> {
|
|
114
170
|
if (initialized) return
|
|
@@ -127,11 +183,32 @@ export function createEventSink(
|
|
|
127
183
|
initialized = true
|
|
128
184
|
}
|
|
129
185
|
|
|
130
|
-
|
|
186
|
+
const sink: EventSink = {
|
|
187
|
+
runId,
|
|
188
|
+
|
|
189
|
+
get isFinalized() {
|
|
190
|
+
return sinkState.finalized
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
markFinalized() {
|
|
194
|
+
sinkState.finalized = true
|
|
195
|
+
try {
|
|
196
|
+
mkdirSync(dirname(markerPath), { recursive: true })
|
|
197
|
+
writeFileSync(markerPath, "")
|
|
198
|
+
} catch (err) {
|
|
199
|
+
logger.warn(`Failed to write finalization marker: ${String(err)}`)
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
|
|
131
203
|
async append(event: AuditEvent): Promise<void> {
|
|
132
204
|
return mutex.run(async () => {
|
|
133
205
|
await ensureInitialized()
|
|
134
206
|
|
|
207
|
+
if (sinkState.finalized && event.type !== "run.finalized") {
|
|
208
|
+
logger.debug(`Dropping ${event.type} for finalized run ${runId}`)
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
135
212
|
if (event.run_id !== runId) {
|
|
136
213
|
throw new EventSinkError(
|
|
137
214
|
"INVALID_EVENT",
|
|
@@ -143,27 +220,18 @@ export function createEventSink(
|
|
|
143
220
|
throw new EventSinkError("INVALID_EVENT", `Invalid event type "${String(event.type)}"`)
|
|
144
221
|
}
|
|
145
222
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
"SEQUENCE_CONFLICT",
|
|
149
|
-
`Event seq ${event.seq} conflicts with last assigned seq ${lastSeq}; must be > ${lastSeq}`,
|
|
150
|
-
)
|
|
151
|
-
}
|
|
152
|
-
|
|
223
|
+
// Best-effort seq hint — may have duplicates across isolated writer instances.
|
|
224
|
+
// Canonical seq is assigned at read time by parseJournalLines().
|
|
153
225
|
const nextSeq = lastSeq + 1
|
|
154
226
|
const eventToWrite: AuditEvent = { ...event, seq: nextSeq }
|
|
155
227
|
|
|
156
|
-
const currentContent = await readRawContent(journalPath)
|
|
157
|
-
const newContent = `${currentContent}${JSON.stringify(eventToWrite)}\n`
|
|
158
|
-
|
|
159
228
|
await mkdir(dirname(journalPath), { recursive: true })
|
|
160
229
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
230
|
+
// O_APPEND atomic write — the OS guarantees that seek-to-end + write is atomic
|
|
231
|
+
// for regular files opened with O_APPEND, so concurrent appends from isolated
|
|
232
|
+
// writer instances won't interleave or overwrite each other.
|
|
164
233
|
try {
|
|
165
|
-
await
|
|
166
|
-
await rename(tempPath, journalPath)
|
|
234
|
+
await appendFile(journalPath, `${JSON.stringify(eventToWrite)}\n`)
|
|
167
235
|
} catch (err) {
|
|
168
236
|
throw new EventSinkError("IO_ERROR", `Failed to write event to journal: ${String(err)}`)
|
|
169
237
|
}
|
|
@@ -177,4 +245,7 @@ export function createEventSink(
|
|
|
177
245
|
return parseJournalLines(content)
|
|
178
246
|
},
|
|
179
247
|
}
|
|
248
|
+
|
|
249
|
+
sinkRegistry.set(runId, sink)
|
|
250
|
+
return sink
|
|
180
251
|
}
|
|
@@ -2,8 +2,13 @@ import { mkdir, writeFile } from "node:fs/promises"
|
|
|
2
2
|
import { dirname } from "node:path"
|
|
3
3
|
import { createAuditArtifactResolver } from "../../shared/audit-artifact-resolver"
|
|
4
4
|
import { dedupeFindingsForFinalOutput } from "../../state/finding-aggregation"
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import {
|
|
6
|
+
projectFindings,
|
|
7
|
+
projectReportInput,
|
|
8
|
+
projectToolExecutions,
|
|
9
|
+
stableHash,
|
|
10
|
+
} from "../../state/projectors"
|
|
11
|
+
import type { CanonicalFinding, CanonicalToolExecution, ReportInput } from "../../state/schemas"
|
|
7
12
|
import { SCHEMA_VERSION } from "../../state/schemas"
|
|
8
13
|
import { readEvents } from "./event-sink"
|
|
9
14
|
|
|
@@ -20,12 +25,34 @@ export interface FindingsArtifact {
|
|
|
20
25
|
toolsExecuted: CanonicalToolExecution[]
|
|
21
26
|
}
|
|
22
27
|
|
|
28
|
+
export interface FindingsMaterializeOptions {
|
|
29
|
+
validateSessionId?: boolean
|
|
30
|
+
requireEvents?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
export async function materializeFindings(
|
|
24
34
|
runId: string,
|
|
25
35
|
projectDir: string,
|
|
26
36
|
sessionId?: string,
|
|
37
|
+
options: FindingsMaterializeOptions = {},
|
|
27
38
|
): Promise<FindingsArtifact> {
|
|
28
39
|
const events = await readEvents(runId, projectDir)
|
|
40
|
+
if (options.requireEvents && events.length === 0) {
|
|
41
|
+
throw new Error(`No events found for run ${runId}`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sessionIdFromEvents = events[0]?.session_id ?? ""
|
|
45
|
+
if (
|
|
46
|
+
options.validateSessionId &&
|
|
47
|
+
sessionId &&
|
|
48
|
+
sessionIdFromEvents.length > 0 &&
|
|
49
|
+
sessionId !== sessionIdFromEvents
|
|
50
|
+
) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Session mismatch for run ${runId}: provided ${sessionId}, event stream has ${sessionIdFromEvents}`,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
29
56
|
const findings = dedupeFindingsForFinalOutput(projectFindings(events))
|
|
30
57
|
const toolsExecuted = projectToolExecutions(events)
|
|
31
58
|
const contentHash = stableHash(JSON.stringify(findings))
|
|
@@ -33,7 +60,7 @@ export async function materializeFindings(
|
|
|
33
60
|
|
|
34
61
|
const artifact: FindingsArtifact = {
|
|
35
62
|
run_id: runId,
|
|
36
|
-
session_id: sessionId ??
|
|
63
|
+
session_id: sessionId ?? sessionIdFromEvents,
|
|
37
64
|
schema_version: SCHEMA_VERSION,
|
|
38
65
|
seq_first: events[0]?.seq ?? 0,
|
|
39
66
|
seq_last: events.at(-1)?.seq ?? 0,
|
|
@@ -50,3 +77,30 @@ export async function materializeFindings(
|
|
|
50
77
|
|
|
51
78
|
return artifact
|
|
52
79
|
}
|
|
80
|
+
|
|
81
|
+
export async function materializeReportInput(
|
|
82
|
+
runId: string,
|
|
83
|
+
projectDir: string,
|
|
84
|
+
_sessionId?: string,
|
|
85
|
+
): Promise<ReportInput> {
|
|
86
|
+
const events = await readEvents(runId, projectDir)
|
|
87
|
+
if (events.length === 0) {
|
|
88
|
+
throw new Error(`No events found for run ${runId}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const reportInput = projectReportInput(events, runId, projectDir)
|
|
92
|
+
|
|
93
|
+
if (reportInput.scope.length === 0 && reportInput.findings.length > 0) {
|
|
94
|
+
reportInput.scope = [...new Set(reportInput.findings.map((f) => f.file).filter(Boolean))]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Cross-run finding import removed: importing findings from sibling runs
|
|
98
|
+
// risks contaminating fresh audits with stale data and breaks per-run provenance.
|
|
99
|
+
// If the primary run has zero findings, the report reflects that accurately.
|
|
100
|
+
|
|
101
|
+
const reportInputFile = createAuditArtifactResolver(runId, projectDir).paths().reportInputFile
|
|
102
|
+
await mkdir(dirname(reportInputFile), { recursive: true })
|
|
103
|
+
await writeFile(reportInputFile, JSON.stringify(reportInput, null, 2))
|
|
104
|
+
|
|
105
|
+
return reportInput
|
|
106
|
+
}
|
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { mkdir } from "node:fs/promises"
|
|
3
|
-
import {
|
|
4
|
-
import { join } from "node:path"
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { appendFile, mkdir } from "node:fs/promises"
|
|
3
|
+
import { getGlobalRunIndexDir, getGlobalRunIndexFile } from "../../shared/cache-paths"
|
|
5
4
|
import { createLogger } from "../../shared/logger"
|
|
6
5
|
|
|
7
6
|
const logger = createLogger()
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
const INDEX_FILE = join(CACHE_DIR, "index.jsonl")
|
|
8
|
+
export type RunStatus = "active" | "finalized" | "failed"
|
|
11
9
|
|
|
12
10
|
export type RunIndexEntry = {
|
|
13
11
|
runId: string
|
|
@@ -18,21 +16,101 @@ export type RunIndexEntry = {
|
|
|
18
16
|
startedAt: number
|
|
19
17
|
phase: string
|
|
20
18
|
findingsCount: number
|
|
19
|
+
status?: RunStatus
|
|
20
|
+
finalizedAt?: number
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
let dirEnsured = false
|
|
24
24
|
|
|
25
25
|
async function ensureDir(): Promise<void> {
|
|
26
26
|
if (dirEnsured) return
|
|
27
|
-
await mkdir(
|
|
27
|
+
await mkdir(getGlobalRunIndexDir(), { recursive: true })
|
|
28
28
|
dirEnsured = true
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export async function recordRun(entry: RunIndexEntry): Promise<void> {
|
|
32
32
|
try {
|
|
33
33
|
await ensureDir()
|
|
34
|
-
|
|
34
|
+
await appendFile(getGlobalRunIndexFile(), `${JSON.stringify(entry)}\n`)
|
|
35
35
|
} catch {
|
|
36
36
|
logger.debug("Failed to write global run index entry")
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
|
|
40
|
+
export async function updateRunStatus(runId: string, status: RunStatus): Promise<void> {
|
|
41
|
+
try {
|
|
42
|
+
await ensureDir()
|
|
43
|
+
const update = { runId, status, finalizedAt: status === "finalized" ? Date.now() : undefined }
|
|
44
|
+
await appendFile(getGlobalRunIndexFile(), `${JSON.stringify(update)}\n`)
|
|
45
|
+
} catch {
|
|
46
|
+
logger.debug("Failed to write run status update")
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const STALE_RUN_TTL_MS = 24 * 60 * 60 * 1000
|
|
51
|
+
|
|
52
|
+
export function resolveRunIdFromOpencodeSession(
|
|
53
|
+
opencodeSessionId: string,
|
|
54
|
+
projectDir?: string,
|
|
55
|
+
): string | null {
|
|
56
|
+
if (typeof opencodeSessionId !== "string" || opencodeSessionId.length === 0) {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const indexFile = getGlobalRunIndexFile()
|
|
61
|
+
|
|
62
|
+
if (!existsSync(indexFile)) {
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const raw = readFileSync(indexFile, "utf-8")
|
|
68
|
+
const lines = raw.split("\n")
|
|
69
|
+
const now = Date.now()
|
|
70
|
+
|
|
71
|
+
const terminatedRunIds = new Set<string>()
|
|
72
|
+
|
|
73
|
+
for (let idx = lines.length - 1; idx >= 0; idx--) {
|
|
74
|
+
const line = lines[idx]
|
|
75
|
+
if (!line || line.trim().length === 0) continue
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(line) as Partial<RunIndexEntry> & { status?: RunStatus }
|
|
79
|
+
|
|
80
|
+
if (parsed.status === "finalized" || parsed.status === "failed") {
|
|
81
|
+
if (typeof parsed.runId === "string") {
|
|
82
|
+
terminatedRunIds.add(parsed.runId)
|
|
83
|
+
}
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (
|
|
88
|
+
parsed.opencodeSessionId === opencodeSessionId &&
|
|
89
|
+
typeof parsed.runId === "string" &&
|
|
90
|
+
parsed.runId.length > 0 &&
|
|
91
|
+
(!projectDir || parsed.projectDir === projectDir)
|
|
92
|
+
) {
|
|
93
|
+
if (terminatedRunIds.has(parsed.runId)) {
|
|
94
|
+
logger.debug(`Skipping terminated run ${parsed.runId}`)
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (typeof parsed.startedAt === "number" && now - parsed.startedAt > STALE_RUN_TTL_MS) {
|
|
99
|
+
logger.debug(
|
|
100
|
+
`Skipping stale run ${parsed.runId} (age: ${Math.round((now - parsed.startedAt) / 3600000)}h)`,
|
|
101
|
+
)
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return parsed.runId
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
logger.debug("Skipping malformed run index line")
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
logger.debug("Failed to read global run index")
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
export { createAuditStateManager } from "./audit-state-manager"
|
|
2
2
|
export type { EventSink, EventSinkErrorCode } from "./event-sink"
|
|
3
|
-
export {
|
|
3
|
+
export {
|
|
4
|
+
createEventSink,
|
|
5
|
+
EventSinkError,
|
|
6
|
+
readEvents,
|
|
7
|
+
releaseEventSink,
|
|
8
|
+
resetSinkRegistry,
|
|
9
|
+
} from "./event-sink"
|
|
@@ -9,10 +9,15 @@ export type FinalizationResult = {
|
|
|
9
9
|
success: boolean
|
|
10
10
|
invariantsPassed: boolean
|
|
11
11
|
errors: string[]
|
|
12
|
+
warnings: string[]
|
|
12
13
|
runId: string
|
|
13
14
|
timestamp: number
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
type ExistingFinalizationResult = FinalizationResult & {
|
|
18
|
+
finalizedIndex: number
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
export function hasSessionCreated(events: AuditEvent[]): boolean {
|
|
17
22
|
return events.some((event) => event.type === "session.created")
|
|
18
23
|
}
|
|
@@ -132,8 +137,52 @@ function collectParentChildIntegrityErrors(events: AuditEvent[]): string[] {
|
|
|
132
137
|
return errors
|
|
133
138
|
}
|
|
134
139
|
|
|
135
|
-
function
|
|
140
|
+
function collectMultiSessionErrors(events: AuditEvent[]): string[] {
|
|
141
|
+
const allSessionIds = new Set(events.map((e) => e.session_id).filter(Boolean))
|
|
142
|
+
if (allSessionIds.size <= 1) return []
|
|
143
|
+
|
|
144
|
+
const knownIds = new Set<string>()
|
|
145
|
+
|
|
146
|
+
for (const event of events) {
|
|
147
|
+
const payload = asRecord(event.payload)
|
|
148
|
+
if (!payload) continue
|
|
149
|
+
const childSessionId = payload.child_session_id
|
|
150
|
+
if (typeof childSessionId === "string" && childSessionId.length > 0) {
|
|
151
|
+
knownIds.add(childSessionId)
|
|
152
|
+
if (event.session_id) {
|
|
153
|
+
knownIds.add(event.session_id)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const event of events) {
|
|
159
|
+
if (event.type !== "session.created") {
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
if (event.session_id && event.session_id.length > 0) {
|
|
163
|
+
knownIds.add(event.session_id)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const firstSessionId = events.find((e) => e.session_id)?.session_id ?? ""
|
|
168
|
+
const unexplained: string[] = []
|
|
169
|
+
for (const id of allSessionIds) {
|
|
170
|
+
if (id === firstSessionId) continue
|
|
171
|
+
if (knownIds.has(id)) continue
|
|
172
|
+
unexplained.push(id)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (unexplained.length > 0) {
|
|
176
|
+
return [
|
|
177
|
+
`unexpected session writers detected (not in parent-child graph): ${unexplained.join(", ")}`,
|
|
178
|
+
]
|
|
179
|
+
}
|
|
180
|
+
return []
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function collectInvariantErrors(events: AuditEvent[]): { errors: string[]; warnings: string[] } {
|
|
136
184
|
const errors: string[] = []
|
|
185
|
+
const warnings: string[] = []
|
|
137
186
|
|
|
138
187
|
try {
|
|
139
188
|
validateEventSequence(events)
|
|
@@ -145,13 +194,56 @@ function collectInvariantErrors(events: AuditEvent[]): string[] {
|
|
|
145
194
|
errors.push("missing required lifecycle event: session.created")
|
|
146
195
|
}
|
|
147
196
|
|
|
148
|
-
|
|
149
|
-
|
|
197
|
+
warnings.push(...collectOrphanedToolStarts(events))
|
|
198
|
+
errors.push(...collectParentChildIntegrityErrors(events))
|
|
199
|
+
errors.push(...collectMultiSessionErrors(events))
|
|
200
|
+
return { errors, warnings }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function parseExistingFinalizationResult(
|
|
204
|
+
events: AuditEvent[],
|
|
205
|
+
runId: string,
|
|
206
|
+
): ExistingFinalizationResult | null {
|
|
207
|
+
const reversedIndex = [...events].reverse().findIndex((event) => event.type === "run.finalized")
|
|
208
|
+
if (reversedIndex < 0) {
|
|
209
|
+
return null
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const finalizedIndex = events.length - 1 - reversedIndex
|
|
213
|
+
const finalized = events[finalizedIndex]
|
|
214
|
+
if (!finalized) {
|
|
215
|
+
return null
|
|
150
216
|
}
|
|
151
217
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
218
|
+
const payload =
|
|
219
|
+
typeof finalized.payload === "object" &&
|
|
220
|
+
finalized.payload !== null &&
|
|
221
|
+
!Array.isArray(finalized.payload)
|
|
222
|
+
? (finalized.payload as Record<string, unknown>)
|
|
223
|
+
: null
|
|
224
|
+
|
|
225
|
+
const errors = Array.isArray(payload?.errors)
|
|
226
|
+
? payload.errors.filter((entry): entry is string => typeof entry === "string")
|
|
227
|
+
: []
|
|
228
|
+
const warnings = Array.isArray(payload?.warnings)
|
|
229
|
+
? payload.warnings.filter((entry): entry is string => typeof entry === "string")
|
|
230
|
+
: []
|
|
231
|
+
const invariantsPassed =
|
|
232
|
+
typeof payload?.invariantsPassed === "boolean"
|
|
233
|
+
? payload.invariantsPassed
|
|
234
|
+
: typeof payload?.finalized === "boolean"
|
|
235
|
+
? payload.finalized
|
|
236
|
+
: errors.length === 0
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
success: invariantsPassed,
|
|
240
|
+
invariantsPassed,
|
|
241
|
+
errors,
|
|
242
|
+
warnings,
|
|
243
|
+
runId,
|
|
244
|
+
timestamp: finalized.timestamp,
|
|
245
|
+
finalizedIndex,
|
|
246
|
+
}
|
|
155
247
|
}
|
|
156
248
|
|
|
157
249
|
export async function finalizeRun(
|
|
@@ -161,7 +253,21 @@ export async function finalizeRun(
|
|
|
161
253
|
): Promise<FinalizationResult> {
|
|
162
254
|
const timestamp = Date.now()
|
|
163
255
|
const events = sink ? await sink.readAll() : await readEvents(runId, projectDir)
|
|
164
|
-
const
|
|
256
|
+
const existingResult = parseExistingFinalizationResult(events, runId)
|
|
257
|
+
const hasEventsAfterExistingFinalization =
|
|
258
|
+
existingResult !== null && existingResult.finalizedIndex < events.length - 1
|
|
259
|
+
if (existingResult?.invariantsPassed && !hasEventsAfterExistingFinalization) {
|
|
260
|
+
return {
|
|
261
|
+
success: existingResult.success,
|
|
262
|
+
invariantsPassed: existingResult.invariantsPassed,
|
|
263
|
+
errors: existingResult.errors,
|
|
264
|
+
warnings: existingResult.warnings,
|
|
265
|
+
runId: existingResult.runId,
|
|
266
|
+
timestamp: existingResult.timestamp,
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const { errors, warnings } = collectInvariantErrors(events)
|
|
165
271
|
const invariantsPassed = errors.length === 0
|
|
166
272
|
const sessionId = events.at(-1)?.session_id ?? ""
|
|
167
273
|
|
|
@@ -178,16 +284,19 @@ export async function finalizeRun(
|
|
|
178
284
|
finalized: invariantsPassed,
|
|
179
285
|
invariantsPassed,
|
|
180
286
|
errors,
|
|
287
|
+
warnings,
|
|
181
288
|
status: invariantsPassed ? "finalized" : "failed-finalization",
|
|
182
289
|
plugin_version: ARGUS_PLUGIN_VERSION,
|
|
183
290
|
},
|
|
184
291
|
})
|
|
292
|
+
sink.markFinalized()
|
|
185
293
|
}
|
|
186
294
|
|
|
187
295
|
return {
|
|
188
296
|
success: invariantsPassed,
|
|
189
297
|
invariantsPassed,
|
|
190
298
|
errors,
|
|
299
|
+
warnings,
|
|
191
300
|
runId,
|
|
192
301
|
timestamp,
|
|
193
302
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { readdir, rm, stat } from "node:fs/promises"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { createLogger } from "../../shared/logger"
|
|
4
|
+
import { type ArgusRootResolver, defaultRootResolver } from "../../shared/path-root-resolver"
|
|
5
|
+
import { readEvents } from "./event-sink"
|
|
6
|
+
|
|
7
|
+
const logger = createLogger()
|
|
8
|
+
|
|
9
|
+
const DEFAULT_STALE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
|
|
10
|
+
const DEFAULT_FINALIZED_RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
|
11
|
+
|
|
12
|
+
export type PruneResult = {
|
|
13
|
+
pruned: string[]
|
|
14
|
+
kept: string[]
|
|
15
|
+
errors: string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isRunFinalized(events: Array<{ type: string }>): boolean {
|
|
19
|
+
return events.some((e) => e.type === "run.finalized")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function pruneStaleRuns(
|
|
23
|
+
projectDir: string,
|
|
24
|
+
options: {
|
|
25
|
+
staleTtlMs?: number
|
|
26
|
+
finalizedRetentionMs?: number
|
|
27
|
+
dryRun?: boolean
|
|
28
|
+
resolver?: ArgusRootResolver
|
|
29
|
+
} = {},
|
|
30
|
+
): Promise<PruneResult> {
|
|
31
|
+
const {
|
|
32
|
+
staleTtlMs = DEFAULT_STALE_TTL_MS,
|
|
33
|
+
finalizedRetentionMs = DEFAULT_FINALIZED_RETENTION_MS,
|
|
34
|
+
dryRun = false,
|
|
35
|
+
resolver = defaultRootResolver,
|
|
36
|
+
} = options
|
|
37
|
+
|
|
38
|
+
const result: PruneResult = { pruned: [], kept: [], errors: [] }
|
|
39
|
+
const runsDir = join(resolver.writeRoot(projectDir), "runs")
|
|
40
|
+
|
|
41
|
+
let entries: string[]
|
|
42
|
+
try {
|
|
43
|
+
entries = await readdir(runsDir)
|
|
44
|
+
} catch {
|
|
45
|
+
return result
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const now = Date.now()
|
|
49
|
+
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
const runDir = join(runsDir, entry)
|
|
52
|
+
try {
|
|
53
|
+
const dirStat = await stat(runDir)
|
|
54
|
+
if (!dirStat.isDirectory()) continue
|
|
55
|
+
|
|
56
|
+
const journalPath = join(runDir, "events.jsonl")
|
|
57
|
+
let journalMtime: number
|
|
58
|
+
try {
|
|
59
|
+
const journalStat = await stat(journalPath)
|
|
60
|
+
journalMtime = journalStat.mtimeMs
|
|
61
|
+
} catch {
|
|
62
|
+
journalMtime = dirStat.mtimeMs
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const age = now - journalMtime
|
|
66
|
+
const events = await readEvents(entry, projectDir, resolver)
|
|
67
|
+
const finalized = isRunFinalized(events)
|
|
68
|
+
|
|
69
|
+
const shouldPrune =
|
|
70
|
+
(finalized && age > finalizedRetentionMs) || (!finalized && age > staleTtlMs)
|
|
71
|
+
|
|
72
|
+
if (shouldPrune) {
|
|
73
|
+
if (!dryRun) {
|
|
74
|
+
await rm(runDir, { recursive: true, force: true })
|
|
75
|
+
}
|
|
76
|
+
result.pruned.push(entry)
|
|
77
|
+
logger.debug(
|
|
78
|
+
`Pruned ${finalized ? "finalized" : "stale"} run ${entry} (age: ${Math.round(age / 3600000)}h)`,
|
|
79
|
+
)
|
|
80
|
+
} else {
|
|
81
|
+
result.kept.push(entry)
|
|
82
|
+
}
|
|
83
|
+
} catch (err) {
|
|
84
|
+
result.errors.push(`${entry}: ${String(err)}`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (result.pruned.length > 0) {
|
|
89
|
+
logger.debug(`Pruned ${result.pruned.length} run(s), kept ${result.kept.length}`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return result
|
|
93
|
+
}
|