@xaccefy/pi-casefile 0.9.4 → 0.10.1
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/README.md +32 -67
- package/package.json +13 -16
- package/src/confirmation.ts +951 -0
- package/src/evidence.ts +131 -4
- package/src/harness-verify.ts +19 -42
- package/src/index.ts +362 -701
- package/src/ledger-internal.ts +435 -0
- package/src/ledger.ts +519 -1255
- package/src/oob-oracle.ts +279 -0
- package/src/poc-runner.ts +51 -12
- package/src/scratchpad.ts +92 -148
- package/src/workflow.ts +48 -325
- package/skills/casefile/SKILL.md +0 -44
- package/src/ledger-worker-entry.ts +0 -35
- package/src/ledger-worker.ts +0 -77
- package/src/pipeline-submit.ts +0 -797
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal shared plumbing for the casefile ledger modules.
|
|
3
|
+
*
|
|
4
|
+
* The single home for the DB handle, record builder, validation, transaction
|
|
5
|
+
* wrapper, and text helpers. Both ledger.ts and its sibling modules
|
|
6
|
+
* (confirmation.ts) import these from here — one copy, no drift. NOT a public
|
|
7
|
+
* API — external callers import from ledger.ts, which re-exports what they need.
|
|
8
|
+
*
|
|
9
|
+
* Circular-import note: this module must stay leaf-like. It may import types
|
|
10
|
+
* from ledger.ts but no runtime values, or the ledger ⇄ sibling cycle gains
|
|
11
|
+
* an edge.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
15
|
+
import type { CaseRecord, EvidenceItem, NormalizedCaseInput } from "./ledger.ts";
|
|
16
|
+
import type { DatabaseSync } from "./sqlite-compat/index.ts";
|
|
17
|
+
|
|
18
|
+
// ── Text helpers ─────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export function normalizeList(values: string[] | undefined): string[] {
|
|
21
|
+
return Array.from(new Set((values ?? []).map((v) => v.trim()).filter(Boolean)));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeText(value: string | undefined): string | undefined {
|
|
25
|
+
const trimmed = value?.trim();
|
|
26
|
+
return trimmed || undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function stableShortId(input: string): string {
|
|
30
|
+
return createHash("sha1").update(input).digest("hex").slice(0, 10);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export { normalizeMatchText };
|
|
34
|
+
|
|
35
|
+
function normalizeMatchText(value: string | undefined): string {
|
|
36
|
+
return normalizeText(value)?.toLowerCase().replace(/\s+/g, " ") ?? "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── DB handle (module-global; ledger.ts owns lifecycle) ─────────────
|
|
40
|
+
|
|
41
|
+
let dbInstance: DatabaseSync | undefined;
|
|
42
|
+
let opener: (() => DatabaseSync) | undefined;
|
|
43
|
+
|
|
44
|
+
/** ledger.ts registers its schema-init opener here at module load. */
|
|
45
|
+
export function setDbOpener(impl: () => DatabaseSync): void {
|
|
46
|
+
opener = impl;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getDb(): DatabaseSync {
|
|
50
|
+
if (!dbInstance && opener) {
|
|
51
|
+
// Lazy open through the owner (schema init + safe-state checks) so any
|
|
52
|
+
// sibling module can start the ledger, not just ledger.ts call sites.
|
|
53
|
+
dbInstance = opener();
|
|
54
|
+
}
|
|
55
|
+
if (!dbInstance) {
|
|
56
|
+
throw new Error("Ledger database not initialized — open it via ledger.ts first");
|
|
57
|
+
}
|
|
58
|
+
return dbInstance;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Called by ledger.ts's getDb() after opening (or reopening) the database. */
|
|
62
|
+
export function setDbInstance(db: DatabaseSync): void {
|
|
63
|
+
dbInstance = db;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function hasDbInstance(): boolean {
|
|
67
|
+
return dbInstance !== undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function closeDb(): void {
|
|
71
|
+
if (!dbInstance) return;
|
|
72
|
+
try {
|
|
73
|
+
dbInstance.close();
|
|
74
|
+
} catch {
|
|
75
|
+
// Best-effort close.
|
|
76
|
+
}
|
|
77
|
+
dbInstance = undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Record building & persistence primitives ────────────────────────
|
|
81
|
+
|
|
82
|
+
export function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRecord {
|
|
83
|
+
const timestamp = new Date().toISOString();
|
|
84
|
+
const title = ("title" in input ? input.title : existing?.title)?.trim() ?? "";
|
|
85
|
+
const id = existing?.id ?? `case_${stableShortId(`${title}\n${timestamp}\n${randomUUID()}`)}`;
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
id,
|
|
89
|
+
title,
|
|
90
|
+
status: input.status ?? existing?.status ?? "hypothesis",
|
|
91
|
+
// Once a case has been investigating/confirmed it never forgets — the kill
|
|
92
|
+
// gate must not be defeatable by demoting first.
|
|
93
|
+
everAdvanced:
|
|
94
|
+
existing?.everAdvanced === true ||
|
|
95
|
+
input.status === "investigating" ||
|
|
96
|
+
input.status === "confirmed",
|
|
97
|
+
confidence: input.confidence ?? existing?.confidence ?? "low",
|
|
98
|
+
severity: input.severity ?? existing?.severity,
|
|
99
|
+
priority: input.priority ?? existing?.priority,
|
|
100
|
+
target: input.target !== undefined ? normalizeText(input.target) : existing?.target,
|
|
101
|
+
endpoint: input.endpoint !== undefined ? normalizeText(input.endpoint) : existing?.endpoint,
|
|
102
|
+
bugClass: input.bugClass !== undefined ? normalizeText(input.bugClass) : existing?.bugClass,
|
|
103
|
+
summary: input.summary !== undefined ? normalizeText(input.summary) : existing?.summary,
|
|
104
|
+
evidence: input.evidence !== undefined ? normalizeText(input.evidence) : existing?.evidence,
|
|
105
|
+
impact: input.impact !== undefined ? normalizeText(input.impact) : existing?.impact,
|
|
106
|
+
nextStep: input.nextStep !== undefined ? normalizeText(input.nextStep) : existing?.nextStep,
|
|
107
|
+
poc: input.poc !== undefined ? normalizeText(input.poc) : existing?.poc,
|
|
108
|
+
remediation:
|
|
109
|
+
input.remediation !== undefined ? normalizeText(input.remediation) : existing?.remediation,
|
|
110
|
+
references: normalizeList(input.references ?? existing?.references),
|
|
111
|
+
blockers: normalizeList(input.blockers ?? existing?.blockers),
|
|
112
|
+
tags: normalizeList(input.tags ?? existing?.tags),
|
|
113
|
+
assumptions: normalizeList(input.assumptions ?? existing?.assumptions),
|
|
114
|
+
disproveIf: normalizeList(input.disproveIf ?? existing?.disproveIf),
|
|
115
|
+
pocVerified: input.pocVerified ?? existing?.pocVerified,
|
|
116
|
+
disconfirmation:
|
|
117
|
+
input.disconfirmation !== undefined
|
|
118
|
+
? normalizeText(input.disconfirmation)
|
|
119
|
+
: existing?.disconfirmation,
|
|
120
|
+
invariant: input.invariant !== undefined ? normalizeText(input.invariant) : existing?.invariant,
|
|
121
|
+
disconfirmationVerified: input.disconfirmationVerified ?? existing?.disconfirmationVerified,
|
|
122
|
+
controlVerified: input.controlVerified ?? existing?.controlVerified,
|
|
123
|
+
pendingConfirmation: input.pendingConfirmation ?? existing?.pendingConfirmation,
|
|
124
|
+
confirmerVerdict: input.confirmerVerdict ?? existing?.confirmerVerdict,
|
|
125
|
+
reportedAt: input.reportedAt ?? existing?.reportedAt,
|
|
126
|
+
reportPath: input.reportPath ?? existing?.reportPath,
|
|
127
|
+
retryPolicy:
|
|
128
|
+
input.retryPolicy !== undefined
|
|
129
|
+
? normalizeRetryPolicy(input.retryPolicy)
|
|
130
|
+
: existing?.retryPolicy,
|
|
131
|
+
evidenceItems: existing?.evidenceItems ?? [],
|
|
132
|
+
coverageItems: existing?.coverageItems ?? [],
|
|
133
|
+
linkedCases: existing?.linkedCases ?? [],
|
|
134
|
+
createdAt: existing?.createdAt ?? timestamp,
|
|
135
|
+
updatedAt: timestamp,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Normalize a retry policy: attempts 1–10, at most 8 distinct fallback models. */
|
|
140
|
+
export function normalizeRetryPolicy(policy: unknown): CaseRecord["retryPolicy"] {
|
|
141
|
+
if (policy === null || policy === undefined) return undefined;
|
|
142
|
+
if (typeof policy !== "object" || Array.isArray(policy)) {
|
|
143
|
+
throw new Error("retry_policy must be an object: { max_attempts, fallback_models? }");
|
|
144
|
+
}
|
|
145
|
+
const p = policy as { max_attempts?: unknown; fallback_models?: unknown };
|
|
146
|
+
if (
|
|
147
|
+
typeof p.max_attempts !== "number" ||
|
|
148
|
+
!Number.isInteger(p.max_attempts) ||
|
|
149
|
+
p.max_attempts < 1 ||
|
|
150
|
+
p.max_attempts > 10
|
|
151
|
+
) {
|
|
152
|
+
throw new Error("retry_policy.max_attempts must be an integer between 1 and 10");
|
|
153
|
+
}
|
|
154
|
+
if (p.fallback_models !== undefined && p.fallback_models !== null) {
|
|
155
|
+
if (!Array.isArray(p.fallback_models) || p.fallback_models.length > 8) {
|
|
156
|
+
throw new Error("retry_policy.fallback_models must be an array of at most 8 model names");
|
|
157
|
+
}
|
|
158
|
+
if (
|
|
159
|
+
!p.fallback_models.every(
|
|
160
|
+
(m) => typeof m === "string" && m.trim().length > 0 && m.trim() === m,
|
|
161
|
+
)
|
|
162
|
+
) {
|
|
163
|
+
throw new Error("retry_policy.fallback_models entries must be non-empty trimmed strings");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const fallbackModels = Array.isArray(p.fallback_models)
|
|
167
|
+
? Array.from(new Set(p.fallback_models.map((m) => m as string)))
|
|
168
|
+
: undefined;
|
|
169
|
+
return fallbackModels?.length
|
|
170
|
+
? { max_attempts: p.max_attempts, fallback_models: fallbackModels }
|
|
171
|
+
: { max_attempts: p.max_attempts };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function validateCase(record: CaseRecord): void {
|
|
175
|
+
if (!record.title.trim()) throw new Error("Case title cannot be empty");
|
|
176
|
+
// Enum membership on the public API path: the tool layer schema-gates these,
|
|
177
|
+
// but direct callers (tests, other integrations) must not be able to persist
|
|
178
|
+
// "bogus" statuses that every later gate and reader would mis-handle.
|
|
179
|
+
const STATUS = [
|
|
180
|
+
"hypothesis",
|
|
181
|
+
"investigating",
|
|
182
|
+
"confirmed",
|
|
183
|
+
"blocked",
|
|
184
|
+
"killed",
|
|
185
|
+
"reported",
|
|
186
|
+
] as const;
|
|
187
|
+
const CONFIDENCE = ["low", "medium", "high"] as const;
|
|
188
|
+
const SEVERITY = ["info", "low", "medium", "high", "critical"] as const;
|
|
189
|
+
const PRIORITY = ["P0", "P1", "P2", "P3", "P4"] as const;
|
|
190
|
+
if (!(STATUS as readonly string[]).includes(record.status)) {
|
|
191
|
+
throw new Error(`Invalid case status: ${record.status}. Statuses: ${STATUS.join(", ")}`);
|
|
192
|
+
}
|
|
193
|
+
if (!(CONFIDENCE as readonly string[]).includes(record.confidence)) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`Invalid case confidence: ${record.confidence}. Confidence levels: ${CONFIDENCE.join(", ")}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
// Null check (not just undefined): DB reads surface absent columns as null.
|
|
199
|
+
if (record.severity != null && !(SEVERITY as readonly string[]).includes(record.severity)) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`Invalid case severity: ${record.severity}. Severities: ${SEVERITY.join(", ")}`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (record.priority != null && !(PRIORITY as readonly string[]).includes(record.priority)) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`Invalid case priority: ${record.priority}. Priorities: ${PRIORITY.join(", ")}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
// Retry policy shape is machine-read by retry tooling — re-check on write.
|
|
210
|
+
if (record.retryPolicy !== undefined) normalizeRetryPolicy(record.retryPolicy);
|
|
211
|
+
// Falsification conditions are load-bearing: they are required at creation
|
|
212
|
+
// and must not be erasable later (CaseUpdate({ disproveIf: [] }) would wipe
|
|
213
|
+
// the hypothesis's falsifiability). Re-check on every write.
|
|
214
|
+
if (record.status !== "reported" && !(record.disproveIf ?? []).some((d) => d.trim())) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
"Cases require disproveIf — falsification conditions (what would disprove this hypothesis). " +
|
|
217
|
+
"They cannot be cleared once set.",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
// Keep this gate in lockstep with the confirmation module: a case may only
|
|
221
|
+
// be CONFIRMED when it has evidence, a PoC, demonstrated impact, a severity,
|
|
222
|
+
// and a named target (what host/repo/scope this affects).
|
|
223
|
+
if (
|
|
224
|
+
record.status === "confirmed" &&
|
|
225
|
+
(!record.evidence ||
|
|
226
|
+
!record.poc ||
|
|
227
|
+
!record.impact ||
|
|
228
|
+
!record.severity ||
|
|
229
|
+
!record.target ||
|
|
230
|
+
!record.disconfirmation)
|
|
231
|
+
) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
"Confirmed cases require evidence, poc, impact, severity, target, and disconfirmation",
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (record.status === "blocked" && (record.blockers ?? []).length === 0) {
|
|
237
|
+
throw new Error("Blocked cases require at least one blocker");
|
|
238
|
+
}
|
|
239
|
+
if (
|
|
240
|
+
record.status === "killed" &&
|
|
241
|
+
!record.evidence &&
|
|
242
|
+
!record.nextStep &&
|
|
243
|
+
(record.blockers ?? []).length === 0 &&
|
|
244
|
+
(record.assumptions ?? []).length === 0
|
|
245
|
+
) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
"Killed cases require evidence, next step, blockers, or assumptions explaining why",
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
// A case becomes REPORTED only after a report FILE that passes the content
|
|
251
|
+
// gate exists on disk. Existence is not enough: any non-empty file — or a
|
|
252
|
+
// directory — would otherwise flip the case to a permanent, immutable state.
|
|
253
|
+
if (record.status === "reported") {
|
|
254
|
+
// Imported lazily as a type-only dependency: validateReportFile is defined
|
|
255
|
+
// in format helpers within ledger.ts and injected here to avoid a runtime
|
|
256
|
+
// cycle. The setter runs at module init in ledger.ts.
|
|
257
|
+
if (validateReportFileImpl === undefined) {
|
|
258
|
+
throw new Error("Report validation unavailable — ledger not fully initialized");
|
|
259
|
+
}
|
|
260
|
+
const reportError = validateReportFileImpl(record.reportPath, record);
|
|
261
|
+
if (reportError) {
|
|
262
|
+
throw new Error(`Reported cases require a valid report file: ${reportError}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let validateReportFileImpl:
|
|
268
|
+
| ((reportPath: string | undefined, record: CaseRecord) => string | null)
|
|
269
|
+
| undefined;
|
|
270
|
+
|
|
271
|
+
export function setValidateReportFile(
|
|
272
|
+
impl: (reportPath: string | undefined, record: CaseRecord) => string | null,
|
|
273
|
+
): void {
|
|
274
|
+
validateReportFileImpl = impl;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function withImmediateTransaction<T>(db: DatabaseSync, fn: () => T): T {
|
|
278
|
+
db.exec("BEGIN IMMEDIATE");
|
|
279
|
+
try {
|
|
280
|
+
const value = fn();
|
|
281
|
+
db.exec("COMMIT");
|
|
282
|
+
return value;
|
|
283
|
+
} catch (err) {
|
|
284
|
+
try {
|
|
285
|
+
db.exec("ROLLBACK");
|
|
286
|
+
} catch {
|
|
287
|
+
// ignore rollback errors
|
|
288
|
+
}
|
|
289
|
+
throw err;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function upsertCase(db: DatabaseSync, record: CaseRecord) {
|
|
294
|
+
// Use ON CONFLICT DO UPDATE (not INSERT OR REPLACE) so FK CASCADE does not
|
|
295
|
+
// wipe case_links when updating an existing primary key.
|
|
296
|
+
const stmt = db.prepare(`
|
|
297
|
+
INSERT INTO cases (
|
|
298
|
+
id, title, status, ever_advanced, confidence, severity, priority, target, endpoint, bugClass,
|
|
299
|
+
summary, evidence, impact, nextStep, poc, remediation,
|
|
300
|
+
references_json, blockers_json, tags_json, assumptions_json, poc_verified_json,
|
|
301
|
+
disconfirmation, disconfirmation_verified_json, disprove_if_json, control_verified_json,
|
|
302
|
+
pending_confirmation_json, confirmer_verdict_json,
|
|
303
|
+
reported_at, report_path, retry_policy_json, invariant, created_at, updated_at
|
|
304
|
+
) VALUES (
|
|
305
|
+
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
306
|
+
?, ?, ?, ?, ?, ?,
|
|
307
|
+
?, ?, ?, ?, ?,
|
|
308
|
+
?, ?, ?, ?,
|
|
309
|
+
?, ?,
|
|
310
|
+
?, ?, ?, ?, ?, ?
|
|
311
|
+
)
|
|
312
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
313
|
+
title = excluded.title,
|
|
314
|
+
status = excluded.status,
|
|
315
|
+
ever_advanced = excluded.ever_advanced,
|
|
316
|
+
confidence = excluded.confidence,
|
|
317
|
+
severity = excluded.severity,
|
|
318
|
+
priority = excluded.priority,
|
|
319
|
+
target = excluded.target,
|
|
320
|
+
endpoint = excluded.endpoint,
|
|
321
|
+
bugClass = excluded.bugClass,
|
|
322
|
+
summary = excluded.summary,
|
|
323
|
+
evidence = excluded.evidence,
|
|
324
|
+
impact = excluded.impact,
|
|
325
|
+
nextStep = excluded.nextStep,
|
|
326
|
+
poc = excluded.poc,
|
|
327
|
+
remediation = excluded.remediation,
|
|
328
|
+
references_json = excluded.references_json,
|
|
329
|
+
blockers_json = excluded.blockers_json,
|
|
330
|
+
tags_json = excluded.tags_json,
|
|
331
|
+
assumptions_json = excluded.assumptions_json,
|
|
332
|
+
poc_verified_json = excluded.poc_verified_json,
|
|
333
|
+
disconfirmation = excluded.disconfirmation,
|
|
334
|
+
disconfirmation_verified_json = excluded.disconfirmation_verified_json,
|
|
335
|
+
disprove_if_json = excluded.disprove_if_json,
|
|
336
|
+
control_verified_json = excluded.control_verified_json,
|
|
337
|
+
pending_confirmation_json = excluded.pending_confirmation_json,
|
|
338
|
+
confirmer_verdict_json = excluded.confirmer_verdict_json,
|
|
339
|
+
invariant = excluded.invariant,
|
|
340
|
+
reported_at = excluded.reported_at,
|
|
341
|
+
report_path = excluded.report_path,
|
|
342
|
+
retry_policy_json = excluded.retry_policy_json,
|
|
343
|
+
created_at = excluded.created_at,
|
|
344
|
+
updated_at = excluded.updated_at
|
|
345
|
+
`);
|
|
346
|
+
|
|
347
|
+
stmt.run(
|
|
348
|
+
record.id,
|
|
349
|
+
record.title,
|
|
350
|
+
record.status,
|
|
351
|
+
record.everAdvanced ? 1 : 0,
|
|
352
|
+
record.confidence,
|
|
353
|
+
record.severity || null,
|
|
354
|
+
record.priority || null,
|
|
355
|
+
record.target || null,
|
|
356
|
+
record.endpoint || null,
|
|
357
|
+
record.bugClass || null,
|
|
358
|
+
record.summary || null,
|
|
359
|
+
record.evidence || null,
|
|
360
|
+
record.impact || null,
|
|
361
|
+
record.nextStep || null,
|
|
362
|
+
record.poc || null,
|
|
363
|
+
record.remediation || null,
|
|
364
|
+
JSON.stringify(record.references),
|
|
365
|
+
JSON.stringify(record.blockers),
|
|
366
|
+
JSON.stringify(record.tags),
|
|
367
|
+
JSON.stringify(record.assumptions),
|
|
368
|
+
record.pocVerified ? JSON.stringify(record.pocVerified) : null,
|
|
369
|
+
record.disconfirmation || null,
|
|
370
|
+
record.disconfirmationVerified ? JSON.stringify(record.disconfirmationVerified) : null,
|
|
371
|
+
JSON.stringify(record.disproveIf),
|
|
372
|
+
record.controlVerified ? JSON.stringify(record.controlVerified) : null,
|
|
373
|
+
record.pendingConfirmation ? JSON.stringify(record.pendingConfirmation) : null,
|
|
374
|
+
record.confirmerVerdict ? JSON.stringify(record.confirmerVerdict) : null,
|
|
375
|
+
record.reportedAt || null,
|
|
376
|
+
record.reportPath || null,
|
|
377
|
+
record.retryPolicy ? JSON.stringify(record.retryPolicy) : null,
|
|
378
|
+
record.invariant || null,
|
|
379
|
+
record.createdAt,
|
|
380
|
+
record.updatedAt,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function insertEvidenceItem(db: DatabaseSync, item: EvidenceItem): void {
|
|
385
|
+
db.prepare(
|
|
386
|
+
`INSERT INTO evidence_items (id, case_id, role, artifact_path, sha256, summary, created_at, contains_secret, secret_findings_json)
|
|
387
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
388
|
+
).run(
|
|
389
|
+
item.id,
|
|
390
|
+
item.caseId,
|
|
391
|
+
item.role,
|
|
392
|
+
item.artifactPath ?? null,
|
|
393
|
+
item.sha256 ?? null,
|
|
394
|
+
item.summary,
|
|
395
|
+
item.createdAt,
|
|
396
|
+
item.containsSecret === true ? 1 : 0,
|
|
397
|
+
item.secretFindings?.length ? JSON.stringify(item.secretFindings) : null,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ── Event journal ────────────────────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
export type CaseEvent = {
|
|
404
|
+
caseId: string;
|
|
405
|
+
seq: number;
|
|
406
|
+
timestamp: string;
|
|
407
|
+
eventType: string;
|
|
408
|
+
actor: string;
|
|
409
|
+
payload?: Record<string, unknown>;
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Append one journal event for a case. Append-only: seq is allocated as
|
|
414
|
+
* max(seq)+1 under the caller's transaction, so events land in commit order.
|
|
415
|
+
* Payloads must stay small and secret-free (field NAMES and ids, not values).
|
|
416
|
+
*/
|
|
417
|
+
export function appendCaseEvent(
|
|
418
|
+
db: DatabaseSync,
|
|
419
|
+
event: { caseId: string; eventType: string; actor?: string; payload?: Record<string, unknown> },
|
|
420
|
+
): void {
|
|
421
|
+
const row = db
|
|
422
|
+
.prepare("SELECT COALESCE(MAX(seq), 0) AS max_seq FROM case_events WHERE case_id = ?")
|
|
423
|
+
.get(event.caseId) as { max_seq: number } | undefined;
|
|
424
|
+
const seq = (row?.max_seq ?? 0) + 1;
|
|
425
|
+
db.prepare(
|
|
426
|
+
"INSERT INTO case_events (case_id, seq, timestamp, event_type, actor, payload_json) VALUES (?, ?, ?, ?, ?, ?)",
|
|
427
|
+
).run(
|
|
428
|
+
event.caseId,
|
|
429
|
+
seq,
|
|
430
|
+
new Date().toISOString(),
|
|
431
|
+
event.eventType,
|
|
432
|
+
event.actor ?? "agent",
|
|
433
|
+
event.payload ? JSON.stringify(event.payload) : null,
|
|
434
|
+
);
|
|
435
|
+
}
|