solidity-argus 0.3.3 → 0.3.5
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/package.json +1 -1
- package/src/agents/argus-prompt.ts +67 -8
- package/src/agents/scribe-prompt.ts +13 -5
- package/src/cli/commands/init.ts +1 -1
- package/src/cli/index.ts +0 -0
- package/src/config/schema.ts +7 -2
- package/src/create-hooks.ts +116 -27
- package/src/features/audit-enforcer/audit-enforcer.ts +31 -2
- package/src/features/migration/index.ts +14 -0
- package/src/features/migration/migration-adapter.ts +151 -0
- package/src/features/migration/parity-telemetry.ts +133 -0
- package/src/features/persistent-state/audit-state-manager.ts +28 -6
- package/src/features/persistent-state/event-sink.ts +175 -0
- package/src/features/persistent-state/findings-materializer.ts +51 -0
- package/src/features/persistent-state/index.ts +2 -0
- package/src/features/persistent-state/run-finalizer.ts +192 -0
- package/src/features/persistent-state/run-journal.ts +15 -4
- package/src/hooks/agent-tracker.ts +15 -0
- package/src/hooks/event-hook.ts +93 -1
- package/src/hooks/system-prompt-hook.ts +20 -0
- package/src/hooks/tool-tracking-hook.ts +263 -33
- package/src/shared/audit-artifact-resolver.ts +75 -0
- package/src/shared/drop-diagnostics.ts +108 -0
- package/src/shared/file-utils.ts +7 -2
- package/src/shared/index.ts +14 -0
- package/src/shared/path-root-resolver.ts +34 -0
- package/src/shared/report-path-resolver.ts +70 -0
- package/src/solodit-lifecycle.ts +86 -7
- package/src/state/adapters.ts +262 -0
- package/src/state/index.ts +15 -0
- package/src/state/projectors.ts +437 -0
- package/src/state/schemas.ts +453 -0
- package/src/state/types.ts +6 -0
- package/src/tools/report-generator-tool.ts +647 -36
- package/src/tools/report-preflight.ts +79 -0
- package/src/tools/solodit-search-tool.ts +15 -24
- package/src/utils/solodit-health.ts +18 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { stableHash } from "../../state/projectors"
|
|
2
|
+
import type { CanonicalFinding } from "../../state/schemas"
|
|
3
|
+
import type { Finding, FindingSeverity } from "../../state/types"
|
|
4
|
+
|
|
5
|
+
const SEVERITIES: readonly FindingSeverity[] = [
|
|
6
|
+
"Critical",
|
|
7
|
+
"High",
|
|
8
|
+
"Medium",
|
|
9
|
+
"Low",
|
|
10
|
+
"Informational",
|
|
11
|
+
] as const
|
|
12
|
+
|
|
13
|
+
export interface SeverityDistribution {
|
|
14
|
+
Critical: number
|
|
15
|
+
High: number
|
|
16
|
+
Medium: number
|
|
17
|
+
Low: number
|
|
18
|
+
Informational: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ParityMetrics {
|
|
22
|
+
legacyFindingCount: number
|
|
23
|
+
canonicalFindingCount: number
|
|
24
|
+
findingCountDiff: number
|
|
25
|
+
legacySeverityDistribution: SeverityDistribution
|
|
26
|
+
canonicalSeverityDistribution: SeverityDistribution
|
|
27
|
+
severityDiffs: Partial<Record<FindingSeverity, number>>
|
|
28
|
+
legacyContentHash: string
|
|
29
|
+
canonicalContentHash: string
|
|
30
|
+
hashMatch: boolean
|
|
31
|
+
onlyInLegacy: string[]
|
|
32
|
+
onlyInCanonical: string[]
|
|
33
|
+
timestamp: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function computeSeverityDistribution(
|
|
37
|
+
findings: Array<{ severity: FindingSeverity }>,
|
|
38
|
+
): SeverityDistribution {
|
|
39
|
+
const dist: SeverityDistribution = {
|
|
40
|
+
Critical: 0,
|
|
41
|
+
High: 0,
|
|
42
|
+
Medium: 0,
|
|
43
|
+
Low: 0,
|
|
44
|
+
Informational: 0,
|
|
45
|
+
}
|
|
46
|
+
for (const f of findings) {
|
|
47
|
+
if (f.severity in dist) {
|
|
48
|
+
dist[f.severity]++
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return dist
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function findingIds(findings: Array<{ id: string }>): Set<string> {
|
|
55
|
+
return new Set(findings.map((f) => f.id))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function computeParityMetrics(
|
|
59
|
+
legacyFindings: Finding[],
|
|
60
|
+
canonicalFindings: CanonicalFinding[],
|
|
61
|
+
): ParityMetrics {
|
|
62
|
+
const legacySeverity = computeSeverityDistribution(legacyFindings)
|
|
63
|
+
const canonicalSeverity = computeSeverityDistribution(canonicalFindings)
|
|
64
|
+
|
|
65
|
+
const severityDiffs: Partial<Record<FindingSeverity, number>> = {}
|
|
66
|
+
for (const sev of SEVERITIES) {
|
|
67
|
+
const diff = canonicalSeverity[sev] - legacySeverity[sev]
|
|
68
|
+
if (diff !== 0) {
|
|
69
|
+
severityDiffs[sev] = diff
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const legacyIds = findingIds(legacyFindings)
|
|
74
|
+
const canonicalIds = findingIds(canonicalFindings)
|
|
75
|
+
|
|
76
|
+
const onlyInLegacy = [...legacyIds].filter((id) => !canonicalIds.has(id))
|
|
77
|
+
const onlyInCanonical = [...canonicalIds].filter((id) => !legacyIds.has(id))
|
|
78
|
+
|
|
79
|
+
const legacyContentHash = stableHash(
|
|
80
|
+
legacyFindings.map((f) => ({ id: f.id, check: f.check, severity: f.severity, file: f.file })),
|
|
81
|
+
)
|
|
82
|
+
const canonicalContentHash = stableHash(
|
|
83
|
+
canonicalFindings.map((f) => ({
|
|
84
|
+
id: f.id,
|
|
85
|
+
check: f.check,
|
|
86
|
+
severity: f.severity,
|
|
87
|
+
file: f.file,
|
|
88
|
+
})),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
legacyFindingCount: legacyFindings.length,
|
|
93
|
+
canonicalFindingCount: canonicalFindings.length,
|
|
94
|
+
findingCountDiff: canonicalFindings.length - legacyFindings.length,
|
|
95
|
+
legacySeverityDistribution: legacySeverity,
|
|
96
|
+
canonicalSeverityDistribution: canonicalSeverity,
|
|
97
|
+
severityDiffs,
|
|
98
|
+
legacyContentHash,
|
|
99
|
+
canonicalContentHash,
|
|
100
|
+
hashMatch: legacyContentHash === canonicalContentHash,
|
|
101
|
+
onlyInLegacy,
|
|
102
|
+
onlyInCanonical,
|
|
103
|
+
timestamp: Date.now(),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function formatParityReport(metrics: ParityMetrics): string {
|
|
108
|
+
const lines: string[] = [
|
|
109
|
+
"=== Migration Parity Report ===",
|
|
110
|
+
`Finding count: legacy=${metrics.legacyFindingCount} canonical=${metrics.canonicalFindingCount} diff=${metrics.findingCountDiff}`,
|
|
111
|
+
`Content hash match: ${metrics.hashMatch}`,
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
const sevDiffs = Object.entries(metrics.severityDiffs)
|
|
115
|
+
if (sevDiffs.length > 0) {
|
|
116
|
+
lines.push(
|
|
117
|
+
`Severity diffs: ${sevDiffs.map(([k, v]) => `${k}=${v > 0 ? "+" : ""}${v}`).join(", ")}`,
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (metrics.onlyInLegacy.length > 0) {
|
|
122
|
+
lines.push(
|
|
123
|
+
`Only in legacy (${metrics.onlyInLegacy.length}): ${metrics.onlyInLegacy.join(", ")}`,
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
if (metrics.onlyInCanonical.length > 0) {
|
|
127
|
+
lines.push(
|
|
128
|
+
`Only in canonical (${metrics.onlyInCanonical.length}): ${metrics.onlyInCanonical.join(", ")}`,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return lines.join("\n")
|
|
133
|
+
}
|
|
@@ -2,10 +2,10 @@ import { mkdir, rename } from "node:fs/promises"
|
|
|
2
2
|
import { dirname, join } from "node:path"
|
|
3
3
|
import type { AuditStateManager } from "../../managers/types"
|
|
4
4
|
import { createLogger } from "../../shared/logger"
|
|
5
|
+
import { type ArgusRootResolver, defaultRootResolver } from "../../shared/path-root-resolver"
|
|
5
6
|
import { createAuditState } from "../../state/audit-state"
|
|
6
7
|
import type { AuditState, PersistentAuditState } from "../../state/types"
|
|
7
8
|
|
|
8
|
-
const STATE_FILE_DIR = ".opencode"
|
|
9
9
|
const STATE_FILE_NAME = "argus-state.json"
|
|
10
10
|
const STATE_VERSION = "2"
|
|
11
11
|
|
|
@@ -95,14 +95,21 @@ export function createDebouncedSave(
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
export function createAuditStateManager(
|
|
98
|
+
export function createAuditStateManager(
|
|
99
|
+
projectDir: string,
|
|
100
|
+
resolver: ArgusRootResolver = defaultRootResolver,
|
|
101
|
+
): AuditStateManager {
|
|
99
102
|
const logger = createLogger()
|
|
100
|
-
|
|
103
|
+
|
|
104
|
+
const stateFilePath = join(resolver.writeRoot(projectDir), STATE_FILE_NAME)
|
|
101
105
|
let currentState: AuditState = createAuditState(projectDir).state
|
|
102
106
|
|
|
103
107
|
async function load(): Promise<AuditState | null> {
|
|
104
108
|
try {
|
|
105
|
-
const
|
|
109
|
+
const resolvedPath = resolver.resolveReadPath(projectDir, STATE_FILE_NAME)
|
|
110
|
+
const readPath = resolvedPath ?? stateFilePath
|
|
111
|
+
|
|
112
|
+
const file = Bun.file(readPath)
|
|
106
113
|
if (!(await file.exists())) {
|
|
107
114
|
return null
|
|
108
115
|
}
|
|
@@ -114,11 +121,19 @@ export function createAuditStateManager(projectDir: string): AuditStateManager {
|
|
|
114
121
|
|
|
115
122
|
const parsed: unknown = JSON.parse(content)
|
|
116
123
|
if (!isPersistentAuditState(parsed)) {
|
|
117
|
-
logger.warn("Persistent audit state is invalid, ignoring",
|
|
124
|
+
logger.warn("Persistent audit state is invalid, ignoring", readPath)
|
|
118
125
|
return null
|
|
119
126
|
}
|
|
120
127
|
|
|
121
|
-
const {
|
|
128
|
+
const {
|
|
129
|
+
savedAt: _savedAt,
|
|
130
|
+
version,
|
|
131
|
+
filePath: _filePath,
|
|
132
|
+
source_of_truth: _sourceOfTruth,
|
|
133
|
+
last_event_seq: snapshotSeq,
|
|
134
|
+
event_stream_hash: _eventStreamHash,
|
|
135
|
+
...state
|
|
136
|
+
} = parsed
|
|
122
137
|
|
|
123
138
|
if (version === "1") {
|
|
124
139
|
if (!state.soloditResults) {
|
|
@@ -129,6 +144,11 @@ export function createAuditStateManager(projectDir: string): AuditStateManager {
|
|
|
129
144
|
}
|
|
130
145
|
}
|
|
131
146
|
|
|
147
|
+
|
|
148
|
+
if (snapshotSeq !== undefined) {
|
|
149
|
+
logger.debug(`Loaded snapshot with last_event_seq=${snapshotSeq} from ${readPath}`)
|
|
150
|
+
}
|
|
151
|
+
|
|
132
152
|
currentState = state
|
|
133
153
|
return currentState
|
|
134
154
|
} catch (err) {
|
|
@@ -154,6 +174,7 @@ export function createAuditStateManager(projectDir: string): AuditStateManager {
|
|
|
154
174
|
savedAt: Date.now(),
|
|
155
175
|
version: STATE_VERSION,
|
|
156
176
|
filePath: stateFilePath,
|
|
177
|
+
source_of_truth: "events",
|
|
157
178
|
}
|
|
158
179
|
|
|
159
180
|
const tempFilePath = `${stateFilePath}.${Date.now()}.tmp`
|
|
@@ -205,6 +226,7 @@ export function createAuditStateManager(projectDir: string): AuditStateManager {
|
|
|
205
226
|
savedAt: Date.now(),
|
|
206
227
|
version: STATE_VERSION,
|
|
207
228
|
filePath: archivePath,
|
|
229
|
+
source_of_truth: "events",
|
|
208
230
|
}
|
|
209
231
|
await Bun.write(archivePath, `${JSON.stringify(persistentState, null, 2)}\n`)
|
|
210
232
|
} catch {
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { mkdir, rename } from "node:fs/promises"
|
|
2
|
+
import { dirname, join } from "node:path"
|
|
3
|
+
import {
|
|
4
|
+
type ArgusRootResolver,
|
|
5
|
+
defaultRootResolver,
|
|
6
|
+
} from "../../shared/path-root-resolver"
|
|
7
|
+
import type { AuditEvent, AuditEventType } from "../../state/schemas"
|
|
8
|
+
|
|
9
|
+
export type EventSinkErrorCode = "SEQUENCE_CONFLICT" | "INVALID_EVENT" | "IO_ERROR"
|
|
10
|
+
|
|
11
|
+
export class EventSinkError extends Error {
|
|
12
|
+
readonly code: EventSinkErrorCode
|
|
13
|
+
|
|
14
|
+
constructor(code: EventSinkErrorCode, message: string) {
|
|
15
|
+
super(message)
|
|
16
|
+
this.name = "EventSinkError"
|
|
17
|
+
this.code = code
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface EventSink {
|
|
22
|
+
append(event: AuditEvent): Promise<void>
|
|
23
|
+
readAll(): Promise<AuditEvent[]>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const VALID_EVENT_TYPES: ReadonlySet<string> = new Set<AuditEventType>([
|
|
27
|
+
"session.created",
|
|
28
|
+
"session.idle",
|
|
29
|
+
"session.deleted",
|
|
30
|
+
"tool.started",
|
|
31
|
+
"tool.completed",
|
|
32
|
+
"finding.added",
|
|
33
|
+
"phase.changed",
|
|
34
|
+
"run.finalized",
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
function createMutex() {
|
|
38
|
+
let chain = Promise.resolve()
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
async run<T>(fn: () => Promise<T>): Promise<T> {
|
|
42
|
+
const prev = chain
|
|
43
|
+
let release!: () => void
|
|
44
|
+
chain = new Promise<void>((r) => {
|
|
45
|
+
release = r
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
await prev
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
return await fn()
|
|
52
|
+
} finally {
|
|
53
|
+
release()
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildJournalPath(runId: string, projectDir: string, resolver: ArgusRootResolver): string {
|
|
60
|
+
return join(resolver.writeRoot(projectDir), "runs", runId, "events.jsonl")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function readRawContent(path: string): Promise<string> {
|
|
64
|
+
const file = Bun.file(path)
|
|
65
|
+
if (!(await file.exists())) {
|
|
66
|
+
return ""
|
|
67
|
+
}
|
|
68
|
+
return file.text()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseJournalLines(content: string): AuditEvent[] {
|
|
72
|
+
if (!content.trim()) return []
|
|
73
|
+
|
|
74
|
+
const lines = content.split("\n").filter(Boolean)
|
|
75
|
+
const events: AuditEvent[] = []
|
|
76
|
+
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
try {
|
|
79
|
+
events.push(JSON.parse(line) as AuditEvent)
|
|
80
|
+
} catch {
|
|
81
|
+
/* skip malformed lines */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
events.sort((a, b) => a.seq - b.seq)
|
|
86
|
+
return events
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Replay-safe stateless read — returns all events for a run sorted by seq.
|
|
91
|
+
*/
|
|
92
|
+
export async function readEvents(runId: string, projectDir: string, resolver: ArgusRootResolver = defaultRootResolver): Promise<AuditEvent[]> {
|
|
93
|
+
const journalPath = buildJournalPath(runId, projectDir, resolver)
|
|
94
|
+
const content = await readRawContent(journalPath)
|
|
95
|
+
return parseJournalLines(content)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Append-only event sink with monotonic seq allocation, in-process mutex,
|
|
100
|
+
* and atomic temp-file-then-rename writes. Restart-safe via journal replay.
|
|
101
|
+
*/
|
|
102
|
+
export function createEventSink(runId: string, projectDir: string, resolver: ArgusRootResolver = defaultRootResolver): EventSink {
|
|
103
|
+
const journalPath = buildJournalPath(runId, projectDir, resolver)
|
|
104
|
+
const mutex = createMutex()
|
|
105
|
+
let lastSeq = 0
|
|
106
|
+
let initialized = false
|
|
107
|
+
|
|
108
|
+
async function ensureInitialized(): Promise<void> {
|
|
109
|
+
if (initialized) return
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const content = await readRawContent(journalPath)
|
|
113
|
+
const events = parseJournalLines(content)
|
|
114
|
+
const lastEvent = events.at(-1)
|
|
115
|
+
if (lastEvent) {
|
|
116
|
+
lastSeq = lastEvent.seq
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
throw new EventSinkError("IO_ERROR", `Failed to initialize event sink: ${String(err)}`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
initialized = true
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
async append(event: AuditEvent): Promise<void> {
|
|
127
|
+
return mutex.run(async () => {
|
|
128
|
+
await ensureInitialized()
|
|
129
|
+
|
|
130
|
+
if (event.run_id !== runId) {
|
|
131
|
+
throw new EventSinkError(
|
|
132
|
+
"INVALID_EVENT",
|
|
133
|
+
`Event run_id "${event.run_id}" does not match sink run_id "${runId}"`,
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!event.type || !VALID_EVENT_TYPES.has(event.type)) {
|
|
138
|
+
throw new EventSinkError("INVALID_EVENT", `Invalid event type "${String(event.type)}"`)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (event.seq > 0 && event.seq <= lastSeq) {
|
|
142
|
+
throw new EventSinkError(
|
|
143
|
+
"SEQUENCE_CONFLICT",
|
|
144
|
+
`Event seq ${event.seq} conflicts with last assigned seq ${lastSeq}; must be > ${lastSeq}`,
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const nextSeq = lastSeq + 1
|
|
149
|
+
const eventToWrite: AuditEvent = { ...event, seq: nextSeq }
|
|
150
|
+
|
|
151
|
+
const currentContent = await readRawContent(journalPath)
|
|
152
|
+
const newContent = `${currentContent}${JSON.stringify(eventToWrite)}\n`
|
|
153
|
+
|
|
154
|
+
await mkdir(dirname(journalPath), { recursive: true })
|
|
155
|
+
|
|
156
|
+
const suffix = `${Date.now()}.${Math.random().toString(36).slice(2)}`
|
|
157
|
+
const tempPath = `${journalPath}.${suffix}.tmp`
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
await Bun.write(tempPath, newContent)
|
|
161
|
+
await rename(tempPath, journalPath)
|
|
162
|
+
} catch (err) {
|
|
163
|
+
throw new EventSinkError("IO_ERROR", `Failed to write event to journal: ${String(err)}`)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
lastSeq = nextSeq
|
|
167
|
+
})
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
async readAll(): Promise<AuditEvent[]> {
|
|
171
|
+
const content = await readRawContent(journalPath)
|
|
172
|
+
return parseJournalLines(content)
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises"
|
|
2
|
+
import { dirname } from "node:path"
|
|
3
|
+
import { createAuditArtifactResolver } from "../../shared/audit-artifact-resolver"
|
|
4
|
+
import { projectFindings, projectToolExecutions, stableHash } from "../../state/projectors"
|
|
5
|
+
import type { CanonicalFinding, CanonicalToolExecution } from "../../state/schemas"
|
|
6
|
+
import { SCHEMA_VERSION } from "../../state/schemas"
|
|
7
|
+
import { readEvents } from "./event-sink"
|
|
8
|
+
|
|
9
|
+
export interface FindingsArtifact {
|
|
10
|
+
run_id: string
|
|
11
|
+
session_id: string
|
|
12
|
+
schema_version: string
|
|
13
|
+
seq_first: number
|
|
14
|
+
seq_last: number
|
|
15
|
+
event_count: number
|
|
16
|
+
content_hash: string
|
|
17
|
+
generated_at: number
|
|
18
|
+
findings: CanonicalFinding[]
|
|
19
|
+
toolsExecuted: CanonicalToolExecution[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function materializeFindings(
|
|
23
|
+
runId: string,
|
|
24
|
+
projectDir: string,
|
|
25
|
+
sessionId?: string,
|
|
26
|
+
): Promise<FindingsArtifact> {
|
|
27
|
+
const events = await readEvents(runId, projectDir)
|
|
28
|
+
const findings = projectFindings(events)
|
|
29
|
+
const toolsExecuted = projectToolExecutions(events)
|
|
30
|
+
const contentHash = stableHash(JSON.stringify(findings))
|
|
31
|
+
const generatedAt = events.at(-1)?.timestamp ?? 0
|
|
32
|
+
|
|
33
|
+
const artifact: FindingsArtifact = {
|
|
34
|
+
run_id: runId,
|
|
35
|
+
session_id: sessionId ?? events[0]?.session_id ?? "",
|
|
36
|
+
schema_version: SCHEMA_VERSION,
|
|
37
|
+
seq_first: events[0]?.seq ?? 0,
|
|
38
|
+
seq_last: events.at(-1)?.seq ?? 0,
|
|
39
|
+
event_count: events.length,
|
|
40
|
+
content_hash: contentHash,
|
|
41
|
+
generated_at: generatedAt,
|
|
42
|
+
findings,
|
|
43
|
+
toolsExecuted,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const findingsFile = createAuditArtifactResolver(runId, projectDir).paths().findingsFile
|
|
47
|
+
await mkdir(dirname(findingsFile), { recursive: true })
|
|
48
|
+
await writeFile(findingsFile, JSON.stringify(artifact, null, 2))
|
|
49
|
+
|
|
50
|
+
return artifact
|
|
51
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { validateEventSequence } from "../../state/projectors"
|
|
2
|
+
import type { AuditEvent } from "../../state/schemas"
|
|
3
|
+
import { SCHEMA_VERSION } from "../../state/schemas"
|
|
4
|
+
import type { EventSink } from "./event-sink"
|
|
5
|
+
import { readEvents } from "./event-sink"
|
|
6
|
+
|
|
7
|
+
export type FinalizationResult = {
|
|
8
|
+
success: boolean
|
|
9
|
+
invariantsPassed: boolean
|
|
10
|
+
errors: string[]
|
|
11
|
+
runId: string
|
|
12
|
+
timestamp: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function hasSessionCreated(events: AuditEvent[]): boolean {
|
|
16
|
+
return events.some((event) => event.type === "session.created")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function hasSessionDeleted(events: AuditEvent[]): boolean {
|
|
20
|
+
return events.some((event) => event.type === "session.deleted")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ToolLifecycleCheckResult = {
|
|
24
|
+
orphanedToolCallIds: string[]
|
|
25
|
+
malformedEvents: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function collectToolLifecycleIssues(events: AuditEvent[]): ToolLifecycleCheckResult {
|
|
29
|
+
const startedCallIds = new Set<string>()
|
|
30
|
+
const completedCallIds = new Set<string>()
|
|
31
|
+
const malformedEvents: string[] = []
|
|
32
|
+
|
|
33
|
+
for (const event of events) {
|
|
34
|
+
if (event.type !== "tool.started" && event.type !== "tool.completed") {
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (typeof event.tool_call_id !== "string" || event.tool_call_id.length === 0) {
|
|
39
|
+
malformedEvents.push(`${event.type} at seq ${event.seq} missing tool_call_id`)
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (event.type === "tool.started") {
|
|
44
|
+
startedCallIds.add(event.tool_call_id)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (event.type === "tool.completed") {
|
|
48
|
+
completedCallIds.add(event.tool_call_id)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const orphanedToolCallIds: string[] = []
|
|
53
|
+
for (const toolCallId of startedCallIds) {
|
|
54
|
+
if (!completedCallIds.has(toolCallId)) {
|
|
55
|
+
orphanedToolCallIds.push(toolCallId)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
orphanedToolCallIds,
|
|
61
|
+
malformedEvents,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function collectOrphanedToolStarts(events: AuditEvent[]): string[] {
|
|
66
|
+
const { orphanedToolCallIds, malformedEvents } = collectToolLifecycleIssues(events)
|
|
67
|
+
const orphanedErrors = orphanedToolCallIds.map(
|
|
68
|
+
(toolCallId) => `orphaned tool.started without matching tool.completed: ${toolCallId}`,
|
|
69
|
+
)
|
|
70
|
+
return [...malformedEvents, ...orphanedErrors]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
74
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
75
|
+
return value as Record<string, unknown>
|
|
76
|
+
}
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function collectParentChildIntegrityErrors(events: AuditEvent[]): string[] {
|
|
81
|
+
const errors: string[] = []
|
|
82
|
+
const parentByChild = new Map<string, string>()
|
|
83
|
+
const correlationByChild = new Map<string, string>()
|
|
84
|
+
|
|
85
|
+
for (const event of events) {
|
|
86
|
+
const payload = asRecord(event.payload)
|
|
87
|
+
if (!payload) {
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const childSessionId = payload.child_session_id
|
|
92
|
+
if (typeof childSessionId !== "string" || childSessionId.length === 0) {
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const parentSessionId = event.session_id
|
|
97
|
+
if (!parentSessionId) {
|
|
98
|
+
errors.push(`child session edge at seq ${event.seq} missing parent session_id`)
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (parentSessionId === childSessionId) {
|
|
103
|
+
errors.push(`child session edge at seq ${event.seq} is self-referential`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const correlationId = payload.correlation_id
|
|
107
|
+
if (typeof correlationId !== "string" || correlationId.length === 0) {
|
|
108
|
+
errors.push(`child session edge at seq ${event.seq} missing correlation_id`)
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const existingParent = parentByChild.get(childSessionId)
|
|
113
|
+
if (existingParent && existingParent !== parentSessionId) {
|
|
114
|
+
errors.push(
|
|
115
|
+
`child session ${childSessionId} mapped to multiple parents: ${existingParent}, ${parentSessionId}`,
|
|
116
|
+
)
|
|
117
|
+
} else {
|
|
118
|
+
parentByChild.set(childSessionId, parentSessionId)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const existingCorrelation = correlationByChild.get(childSessionId)
|
|
122
|
+
if (existingCorrelation && existingCorrelation !== correlationId) {
|
|
123
|
+
errors.push(
|
|
124
|
+
`child session ${childSessionId} has inconsistent correlation_id: ${existingCorrelation}, ${correlationId}`,
|
|
125
|
+
)
|
|
126
|
+
} else {
|
|
127
|
+
correlationByChild.set(childSessionId, correlationId)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return errors
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function collectInvariantErrors(events: AuditEvent[]): string[] {
|
|
135
|
+
const errors: string[] = []
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
validateEventSequence(events)
|
|
139
|
+
} catch (error) {
|
|
140
|
+
errors.push(`invalid event sequence: ${error instanceof Error ? error.message : String(error)}`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!hasSessionCreated(events)) {
|
|
144
|
+
errors.push("missing required lifecycle event: session.created")
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!hasSessionDeleted(events)) {
|
|
148
|
+
errors.push("missing required lifecycle event: session.deleted")
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
errors.push(...collectOrphanedToolStarts(events))
|
|
152
|
+
errors.push(...collectParentChildIntegrityErrors(events))
|
|
153
|
+
return errors
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function finalizeRun(
|
|
157
|
+
runId: string,
|
|
158
|
+
projectDir: string,
|
|
159
|
+
sink: EventSink | null,
|
|
160
|
+
): Promise<FinalizationResult> {
|
|
161
|
+
const timestamp = Date.now()
|
|
162
|
+
const events = sink ? await sink.readAll() : await readEvents(runId, projectDir)
|
|
163
|
+
const errors = collectInvariantErrors(events)
|
|
164
|
+
const invariantsPassed = errors.length === 0
|
|
165
|
+
const sessionId = events.at(-1)?.session_id ?? ""
|
|
166
|
+
|
|
167
|
+
if (sink) {
|
|
168
|
+
await sink.append({
|
|
169
|
+
type: "run.finalized",
|
|
170
|
+
run_id: runId,
|
|
171
|
+
seq: 0,
|
|
172
|
+
session_id: sessionId,
|
|
173
|
+
source: "run-finalizer",
|
|
174
|
+
schema_version: SCHEMA_VERSION,
|
|
175
|
+
timestamp,
|
|
176
|
+
payload: {
|
|
177
|
+
finalized: invariantsPassed,
|
|
178
|
+
invariantsPassed,
|
|
179
|
+
errors,
|
|
180
|
+
status: invariantsPassed ? "finalized" : "failed-finalization",
|
|
181
|
+
},
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
success: invariantsPassed,
|
|
187
|
+
invariantsPassed,
|
|
188
|
+
errors,
|
|
189
|
+
runId,
|
|
190
|
+
timestamp,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { appendFile, mkdir } from "node:fs/promises"
|
|
2
2
|
import { dirname, join } from "node:path"
|
|
3
|
+
import {
|
|
4
|
+
type ArgusRootResolver,
|
|
5
|
+
defaultRootResolver,
|
|
6
|
+
} from "../../shared/path-root-resolver"
|
|
3
7
|
import { createLogger } from "../../shared/logger"
|
|
4
8
|
|
|
5
9
|
const logger = createLogger()
|
|
6
10
|
|
|
7
|
-
const JOURNAL_DIR = ".opencode"
|
|
8
11
|
const JOURNAL_FILE = "argus-journal.jsonl"
|
|
9
12
|
|
|
10
13
|
export type JournalEvent =
|
|
@@ -15,7 +18,12 @@ export type JournalEvent =
|
|
|
15
18
|
findingsCount: number
|
|
16
19
|
toolsExecutedCount: number
|
|
17
20
|
}
|
|
18
|
-
| {
|
|
21
|
+
| {
|
|
22
|
+
type: "session.deleted"
|
|
23
|
+
timestamp: number
|
|
24
|
+
archived: boolean
|
|
25
|
+
finalizationPassed: boolean | null
|
|
26
|
+
}
|
|
19
27
|
| {
|
|
20
28
|
type: "tool.executed"
|
|
21
29
|
tool: string
|
|
@@ -30,12 +38,15 @@ export type JournalEvent =
|
|
|
30
38
|
findingsCount: number
|
|
31
39
|
}
|
|
32
40
|
|
|
33
|
-
export function createRunJournal(
|
|
41
|
+
export function createRunJournal(
|
|
42
|
+
projectDir: string,
|
|
43
|
+
resolver: ArgusRootResolver = defaultRootResolver,
|
|
44
|
+
): {
|
|
34
45
|
log(event: JournalEvent): void
|
|
35
46
|
close(): Promise<void>
|
|
36
47
|
getPath(): string
|
|
37
48
|
} {
|
|
38
|
-
const journalPath = join(projectDir,
|
|
49
|
+
const journalPath = join(resolver.writeRoot(projectDir), JOURNAL_FILE)
|
|
39
50
|
let ensureDirPromise: Promise<void> | null = null
|
|
40
51
|
const pendingWrites = new Set<Promise<void>>()
|
|
41
52
|
|