solidity-argus 0.3.2 → 0.3.4

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.
@@ -0,0 +1,171 @@
1
+ import { mkdir, rename } from "node:fs/promises"
2
+ import { dirname, join } from "node:path"
3
+ import type { AuditEvent, AuditEventType } from "../../state/schemas"
4
+
5
+ export type EventSinkErrorCode = "SEQUENCE_CONFLICT" | "INVALID_EVENT" | "IO_ERROR"
6
+
7
+ export class EventSinkError extends Error {
8
+ readonly code: EventSinkErrorCode
9
+
10
+ constructor(code: EventSinkErrorCode, message: string) {
11
+ super(message)
12
+ this.name = "EventSinkError"
13
+ this.code = code
14
+ }
15
+ }
16
+
17
+ export interface EventSink {
18
+ append(event: AuditEvent): Promise<void>
19
+ readAll(): Promise<AuditEvent[]>
20
+ }
21
+
22
+ const VALID_EVENT_TYPES: ReadonlySet<string> = new Set<AuditEventType>([
23
+ "session.created",
24
+ "session.idle",
25
+ "session.deleted",
26
+ "tool.started",
27
+ "tool.completed",
28
+ "finding.added",
29
+ "phase.changed",
30
+ "run.finalized",
31
+ ])
32
+
33
+ function createMutex() {
34
+ let chain = Promise.resolve()
35
+
36
+ return {
37
+ async run<T>(fn: () => Promise<T>): Promise<T> {
38
+ const prev = chain
39
+ let release!: () => void
40
+ chain = new Promise<void>((r) => {
41
+ release = r
42
+ })
43
+
44
+ await prev
45
+
46
+ try {
47
+ return await fn()
48
+ } finally {
49
+ release()
50
+ }
51
+ },
52
+ }
53
+ }
54
+
55
+ function buildJournalPath(runId: string, projectDir: string): string {
56
+ return join(projectDir, ".opencode", "runs", runId, "events.jsonl")
57
+ }
58
+
59
+ async function readRawContent(path: string): Promise<string> {
60
+ const file = Bun.file(path)
61
+ if (!(await file.exists())) {
62
+ return ""
63
+ }
64
+ return file.text()
65
+ }
66
+
67
+ function parseJournalLines(content: string): AuditEvent[] {
68
+ if (!content.trim()) return []
69
+
70
+ const lines = content.split("\n").filter(Boolean)
71
+ const events: AuditEvent[] = []
72
+
73
+ for (const line of lines) {
74
+ try {
75
+ events.push(JSON.parse(line) as AuditEvent)
76
+ } catch {
77
+ /* skip malformed lines */
78
+ }
79
+ }
80
+
81
+ events.sort((a, b) => a.seq - b.seq)
82
+ return events
83
+ }
84
+
85
+ /**
86
+ * Replay-safe stateless read — returns all events for a run sorted by seq.
87
+ */
88
+ export async function readEvents(runId: string, projectDir: string): Promise<AuditEvent[]> {
89
+ const journalPath = buildJournalPath(runId, projectDir)
90
+ const content = await readRawContent(journalPath)
91
+ return parseJournalLines(content)
92
+ }
93
+
94
+ /**
95
+ * Append-only event sink with monotonic seq allocation, in-process mutex,
96
+ * and atomic temp-file-then-rename writes. Restart-safe via journal replay.
97
+ */
98
+ export function createEventSink(runId: string, projectDir: string): EventSink {
99
+ const journalPath = buildJournalPath(runId, projectDir)
100
+ const mutex = createMutex()
101
+ let lastSeq = 0
102
+ let initialized = false
103
+
104
+ async function ensureInitialized(): Promise<void> {
105
+ if (initialized) return
106
+
107
+ try {
108
+ const content = await readRawContent(journalPath)
109
+ const events = parseJournalLines(content)
110
+ const lastEvent = events.at(-1)
111
+ if (lastEvent) {
112
+ lastSeq = lastEvent.seq
113
+ }
114
+ } catch (err) {
115
+ throw new EventSinkError("IO_ERROR", `Failed to initialize event sink: ${String(err)}`)
116
+ }
117
+
118
+ initialized = true
119
+ }
120
+
121
+ return {
122
+ async append(event: AuditEvent): Promise<void> {
123
+ return mutex.run(async () => {
124
+ await ensureInitialized()
125
+
126
+ if (event.run_id !== runId) {
127
+ throw new EventSinkError(
128
+ "INVALID_EVENT",
129
+ `Event run_id "${event.run_id}" does not match sink run_id "${runId}"`,
130
+ )
131
+ }
132
+
133
+ if (!event.type || !VALID_EVENT_TYPES.has(event.type)) {
134
+ throw new EventSinkError("INVALID_EVENT", `Invalid event type "${String(event.type)}"`)
135
+ }
136
+
137
+ if (event.seq > 0 && event.seq <= lastSeq) {
138
+ throw new EventSinkError(
139
+ "SEQUENCE_CONFLICT",
140
+ `Event seq ${event.seq} conflicts with last assigned seq ${lastSeq}; must be > ${lastSeq}`,
141
+ )
142
+ }
143
+
144
+ const nextSeq = lastSeq + 1
145
+ const eventToWrite: AuditEvent = { ...event, seq: nextSeq }
146
+
147
+ const currentContent = await readRawContent(journalPath)
148
+ const newContent = `${currentContent}${JSON.stringify(eventToWrite)}\n`
149
+
150
+ await mkdir(dirname(journalPath), { recursive: true })
151
+
152
+ const suffix = `${Date.now()}.${Math.random().toString(36).slice(2)}`
153
+ const tempPath = `${journalPath}.${suffix}.tmp`
154
+
155
+ try {
156
+ await Bun.write(tempPath, newContent)
157
+ await rename(tempPath, journalPath)
158
+ } catch (err) {
159
+ throw new EventSinkError("IO_ERROR", `Failed to write event to journal: ${String(err)}`)
160
+ }
161
+
162
+ lastSeq = nextSeq
163
+ })
164
+ },
165
+
166
+ async readAll(): Promise<AuditEvent[]> {
167
+ const content = await readRawContent(journalPath)
168
+ return parseJournalLines(content)
169
+ },
170
+ }
171
+ }
@@ -1 +1,3 @@
1
1
  export { createAuditStateManager } from "./audit-state-manager"
2
+ export { createEventSink, readEvents, EventSinkError } from "./event-sink"
3
+ export type { EventSink, EventSinkErrorCode } from "./event-sink"
@@ -0,0 +1,175 @@
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
+ function hasSessionCreated(events: AuditEvent[]): boolean {
16
+ return events.some((event) => event.type === "session.created")
17
+ }
18
+
19
+ function hasSessionDeleted(events: AuditEvent[]): boolean {
20
+ return events.some((event) => event.type === "session.deleted")
21
+ }
22
+
23
+ function collectOrphanedToolStarts(events: AuditEvent[]): string[] {
24
+ const startedCallIds = new Set<string>()
25
+ const completedCallIds = new Set<string>()
26
+ const errors: string[] = []
27
+
28
+ for (const event of events) {
29
+ if (event.type !== "tool.started" && event.type !== "tool.completed") {
30
+ continue
31
+ }
32
+
33
+ if (typeof event.tool_call_id !== "string" || event.tool_call_id.length === 0) {
34
+ errors.push(`${event.type} at seq ${event.seq} missing tool_call_id`)
35
+ continue
36
+ }
37
+
38
+ if (event.type === "tool.started") {
39
+ startedCallIds.add(event.tool_call_id)
40
+ }
41
+
42
+ if (event.type === "tool.completed") {
43
+ completedCallIds.add(event.tool_call_id)
44
+ }
45
+ }
46
+
47
+ for (const toolCallId of startedCallIds) {
48
+ if (!completedCallIds.has(toolCallId)) {
49
+ errors.push(`orphaned tool.started without matching tool.completed: ${toolCallId}`)
50
+ }
51
+ }
52
+
53
+ return errors
54
+ }
55
+
56
+ function asRecord(value: unknown): Record<string, unknown> | null {
57
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
58
+ return value as Record<string, unknown>
59
+ }
60
+ return null
61
+ }
62
+
63
+ function collectParentChildIntegrityErrors(events: AuditEvent[]): string[] {
64
+ const errors: string[] = []
65
+ const parentByChild = new Map<string, string>()
66
+ const correlationByChild = new Map<string, string>()
67
+
68
+ for (const event of events) {
69
+ const payload = asRecord(event.payload)
70
+ if (!payload) {
71
+ continue
72
+ }
73
+
74
+ const childSessionId = payload.child_session_id
75
+ if (typeof childSessionId !== "string" || childSessionId.length === 0) {
76
+ continue
77
+ }
78
+
79
+ const parentSessionId = event.session_id
80
+ if (!parentSessionId) {
81
+ errors.push(`child session edge at seq ${event.seq} missing parent session_id`)
82
+ continue
83
+ }
84
+
85
+ if (parentSessionId === childSessionId) {
86
+ errors.push(`child session edge at seq ${event.seq} is self-referential`)
87
+ }
88
+
89
+ const correlationId = payload.correlation_id
90
+ if (typeof correlationId !== "string" || correlationId.length === 0) {
91
+ errors.push(`child session edge at seq ${event.seq} missing correlation_id`)
92
+ continue
93
+ }
94
+
95
+ const existingParent = parentByChild.get(childSessionId)
96
+ if (existingParent && existingParent !== parentSessionId) {
97
+ errors.push(
98
+ `child session ${childSessionId} mapped to multiple parents: ${existingParent}, ${parentSessionId}`,
99
+ )
100
+ } else {
101
+ parentByChild.set(childSessionId, parentSessionId)
102
+ }
103
+
104
+ const existingCorrelation = correlationByChild.get(childSessionId)
105
+ if (existingCorrelation && existingCorrelation !== correlationId) {
106
+ errors.push(
107
+ `child session ${childSessionId} has inconsistent correlation_id: ${existingCorrelation}, ${correlationId}`,
108
+ )
109
+ } else {
110
+ correlationByChild.set(childSessionId, correlationId)
111
+ }
112
+ }
113
+
114
+ return errors
115
+ }
116
+
117
+ function collectInvariantErrors(events: AuditEvent[]): string[] {
118
+ const errors: string[] = []
119
+
120
+ try {
121
+ validateEventSequence(events)
122
+ } catch (error) {
123
+ errors.push(`invalid event sequence: ${error instanceof Error ? error.message : String(error)}`)
124
+ }
125
+
126
+ if (!hasSessionCreated(events)) {
127
+ errors.push("missing required lifecycle event: session.created")
128
+ }
129
+
130
+ if (!hasSessionDeleted(events)) {
131
+ errors.push("missing required lifecycle event: session.deleted")
132
+ }
133
+
134
+ errors.push(...collectOrphanedToolStarts(events))
135
+ errors.push(...collectParentChildIntegrityErrors(events))
136
+ return errors
137
+ }
138
+
139
+ export async function finalizeRun(
140
+ runId: string,
141
+ projectDir: string,
142
+ sink: EventSink | null,
143
+ ): Promise<FinalizationResult> {
144
+ const timestamp = Date.now()
145
+ const events = sink ? await sink.readAll() : await readEvents(runId, projectDir)
146
+ const errors = collectInvariantErrors(events)
147
+ const invariantsPassed = errors.length === 0
148
+ const sessionId = events.at(-1)?.session_id ?? ""
149
+
150
+ if (sink) {
151
+ await sink.append({
152
+ type: "run.finalized",
153
+ run_id: runId,
154
+ seq: 0,
155
+ session_id: sessionId,
156
+ source: "run-finalizer",
157
+ schema_version: SCHEMA_VERSION,
158
+ timestamp,
159
+ payload: {
160
+ finalized: invariantsPassed,
161
+ invariantsPassed,
162
+ errors,
163
+ status: invariantsPassed ? "finalized" : "failed-finalization",
164
+ },
165
+ })
166
+ }
167
+
168
+ return {
169
+ success: invariantsPassed,
170
+ invariantsPassed,
171
+ errors,
172
+ runId,
173
+ timestamp,
174
+ }
175
+ }
@@ -15,7 +15,7 @@ export type JournalEvent =
15
15
  findingsCount: number
16
16
  toolsExecutedCount: number
17
17
  }
18
- | { type: "session.deleted"; timestamp: number; archived: boolean }
18
+ | { type: "session.deleted"; timestamp: number; archived: boolean; finalizationPassed: boolean | null }
19
19
  | {
20
20
  type: "tool.executed"
21
21
  tool: string
@@ -11,6 +11,7 @@ export type AgentTracker = ReturnType<typeof createAgentTracker>
11
11
 
12
12
  export function createAgentTracker() {
13
13
  const sessions = new Map<string, string>()
14
+ const childSessions = new Map<string, Set<string>>()
14
15
 
15
16
  const trackSession = (sessionID: string, agent?: string): void => {
16
17
  if (!agent) {
@@ -49,5 +50,19 @@ export function createAgentTracker() {
49
50
  getTrackedSessions: (): Map<string, string> => {
50
51
  return sessions
51
52
  },
53
+
54
+ trackChildSession: (parentSessionId: string, childSessionId: string): void => {
55
+ let children = childSessions.get(parentSessionId)
56
+ if (!children) {
57
+ children = new Set()
58
+ childSessions.set(parentSessionId, children)
59
+ }
60
+ children.add(childSessionId)
61
+ },
62
+
63
+ getChildSessions: (parentSessionId: string): string[] => {
64
+ const children = childSessions.get(parentSessionId)
65
+ return children ? Array.from(children) : []
66
+ },
52
67
  }
53
68
  }
@@ -1,5 +1,10 @@
1
+ import type { EventSink } from "../features/persistent-state/event-sink"
2
+ import { finalizeRun } from "../features/persistent-state/run-finalizer"
3
+ import type { FinalizationResult } from "../features/persistent-state/run-finalizer"
1
4
  import { createLogger } from "../shared/logger"
2
5
  import { createAuditState } from "../state/audit-state"
6
+ import type { AuditEvent } from "../state/schemas"
7
+ import { SCHEMA_VERSION } from "../state/schemas"
3
8
  import type { AuditState } from "../state/types"
4
9
 
5
10
  export type AuditEventType =
@@ -29,17 +34,50 @@ export function createEventHook(
29
34
  hook: EventHookFn
30
35
  getAuditState: () => AuditState | null
31
36
  setAuditState: (state: AuditState | null) => void
37
+ setEventSink: (sink: EventSink | null) => void
38
+ getLastFinalizationResult: () => FinalizationResult | null
32
39
  } {
33
40
  const logger = createLogger()
34
41
  let currentAuditState: AuditState | null = null
42
+ let eventSink: EventSink | null = null
43
+ let lastFinalizationResult: FinalizationResult | null = null
35
44
 
36
45
  const getAuditState = (): AuditState | null => currentAuditState
37
46
  const setAuditState = (state: AuditState | null): void => {
38
47
  currentAuditState = state
39
48
  }
49
+ const setEventSink = (sink: EventSink | null): void => {
50
+ eventSink = sink
51
+ }
52
+
53
+ async function emitToSink(
54
+ type: AuditEvent["type"],
55
+ runId: string,
56
+ sessionId: string | undefined,
57
+ payload: unknown,
58
+ ): Promise<void> {
59
+ if (!eventSink) return
60
+ try {
61
+ await eventSink.append({
62
+ type,
63
+ run_id: runId,
64
+ seq: 0, // auto-assigned by sink
65
+ session_id: sessionId ?? "",
66
+ source: "event-hook",
67
+ schema_version: SCHEMA_VERSION,
68
+ timestamp: Date.now(),
69
+ payload,
70
+ })
71
+ } catch (error) {
72
+ logger.error(
73
+ `Failed to emit ${type} event to sink: ${error instanceof Error ? error.message : String(error)}`,
74
+ )
75
+ }
76
+ }
40
77
 
41
78
  const hook: EventHookFn = async (input): Promise<void> => {
42
79
  const { type, sessionId } = input.event
80
+ let preDeleteState: AuditState | null = null
43
81
 
44
82
  switch (type) {
45
83
  case "session.created": {
@@ -73,6 +111,7 @@ export function createEventHook(
73
111
  }
74
112
 
75
113
  case "session.deleted": {
114
+ preDeleteState = currentAuditState
76
115
  currentAuditState = null
77
116
  break
78
117
  }
@@ -93,7 +132,60 @@ export function createEventHook(
93
132
  logger.error(`Sub-handler failed for event ${type}:`, error)
94
133
  }
95
134
  }
135
+
136
+ // Emit canonical events to sink (after sub-handlers, so sink may have been set during session.created)
137
+ switch (type) {
138
+ case "session.created": {
139
+ if (currentAuditState) {
140
+ await emitToSink("session.created", currentAuditState.sessionId, sessionId, {
141
+ projectDir: currentAuditState.projectDir,
142
+ sessionId: currentAuditState.sessionId,
143
+ })
144
+ }
145
+ break
146
+ }
147
+
148
+ case "session.idle": {
149
+ if (currentAuditState) {
150
+ await emitToSink("session.idle", currentAuditState.sessionId, sessionId, {
151
+ findingsCount: currentAuditState.findings.length,
152
+ toolsExecutedCount: currentAuditState.toolsExecuted.length,
153
+ phase: currentAuditState.currentPhase,
154
+ })
155
+ }
156
+ break
157
+ }
158
+
159
+ case "session.deleted": {
160
+ if (preDeleteState) {
161
+ await emitToSink("session.deleted", preDeleteState.sessionId, sessionId, {
162
+ archived: true,
163
+ })
164
+
165
+ if (eventSink) {
166
+ try {
167
+ lastFinalizationResult = await finalizeRun(
168
+ preDeleteState.sessionId,
169
+ preDeleteState.projectDir,
170
+ eventSink,
171
+ )
172
+ } catch (error) {
173
+ logger.error(
174
+ `Failed to finalize run ${preDeleteState.sessionId}: ${error instanceof Error ? error.message : String(error)}`,
175
+ )
176
+ }
177
+ }
178
+ }
179
+ eventSink = null
180
+ break
181
+ }
182
+
183
+ default:
184
+ break
185
+ }
96
186
  }
97
187
 
98
- return { hook, getAuditState, setAuditState }
188
+ const getLastFinalizationResult = (): FinalizationResult | null => lastFinalizationResult
189
+
190
+ return { hook, getAuditState, setAuditState, setEventSink, getLastFinalizationResult }
99
191
  }