@wrongstack/core 0.302.0 → 0.303.0

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.
Files changed (74) hide show
  1. package/README.md +1 -1
  2. package/dist/agent-status-tracker.d.ts +6 -2
  3. package/dist/chronicle/index.js +1949 -1671
  4. package/dist/chronicle/metrics-store.d.ts +14 -0
  5. package/dist/chronicle/project-server-protocol.d.ts +13 -0
  6. package/dist/chronicle/project-server.js +1756 -1573
  7. package/dist/chronicle/rollup-adapter.d.ts +2 -0
  8. package/dist/chronicle/sqlite-journal.d.ts +59 -0
  9. package/dist/coordination/agents/index.js +4313 -3516
  10. package/dist/coordination/agents/project-agent-auto-optimize.d.ts +116 -0
  11. package/dist/coordination/agents/project-agent-capture-window.d.ts +29 -0
  12. package/dist/coordination/agents/project-agent-config-io.d.ts +11 -0
  13. package/dist/coordination/agents/project-agent-consolidation.d.ts +29 -2
  14. package/dist/coordination/agents/project-agent-files.d.ts +12 -3
  15. package/dist/coordination/agents/project-agent-identity-types.d.ts +4 -0
  16. package/dist/coordination/agents/project-agent-identity.d.ts +22 -9
  17. package/dist/coordination/agents/project-agent-learning-entries.d.ts +8 -2
  18. package/dist/coordination/agents/project-agent-learning-structured.d.ts +27 -1
  19. package/dist/coordination/agents/project-agent-optimizer.d.ts +49 -0
  20. package/dist/coordination/agents/project-agent-skill-layer.d.ts +101 -0
  21. package/dist/coordination/agents/role-skills.d.ts +11 -1
  22. package/dist/coordination/index.d.ts +1 -1
  23. package/dist/coordination/index.js +4927 -3589
  24. package/dist/coordination/mail-tools.d.ts +3 -3
  25. package/dist/core/context.d.ts +4 -0
  26. package/dist/core/continue-intent.d.ts +2 -0
  27. package/dist/core/conversation-state.d.ts +5 -0
  28. package/dist/core/index.js +129 -19
  29. package/dist/defaults/index.js +1620 -768
  30. package/dist/execution/index.js +2941 -2630
  31. package/dist/goal/index.js +7 -0
  32. package/dist/index.d.ts +3 -1
  33. package/dist/index.js +12269 -9212
  34. package/dist/infrastructure/index.js +722 -672
  35. package/dist/kernel/events/agent-events.d.ts +28 -0
  36. package/dist/kernel/events/memory-events.d.ts +62 -0
  37. package/dist/plugin/index.js +2167 -1986
  38. package/dist/security/index.js +69 -3
  39. package/dist/security/kanban-boundary.d.ts +5 -1
  40. package/dist/session-catalog/client.d.ts +62 -0
  41. package/dist/session-catalog/endpoint.d.ts +6 -0
  42. package/dist/session-catalog/index.d.ts +6 -0
  43. package/dist/session-catalog/index.js +2000 -0
  44. package/dist/session-catalog/project-server.d.ts +3 -0
  45. package/dist/session-catalog/project-server.js +1861 -0
  46. package/dist/session-catalog/protocol.d.ts +284 -0
  47. package/dist/session-catalog/registry.d.ts +59 -0
  48. package/dist/session-catalog/store.d.ts +71 -0
  49. package/dist/storage/index.d.ts +42 -38
  50. package/dist/storage/index.js +13896 -12931
  51. package/dist/storage/plan-store.d.ts +1 -1
  52. package/dist/storage/session-event-bridge.d.ts +2 -2
  53. package/dist/storage/session-store.d.ts +6 -0
  54. package/dist/tasking/index.js +5 -0
  55. package/dist/tools/index.js +2832 -2606
  56. package/dist/types/config/root.d.ts +11 -1
  57. package/dist/types/config/skills-fleet-brain.d.ts +34 -0
  58. package/dist/types/config/ui.d.ts +14 -0
  59. package/dist/types/config.d.ts +1 -0
  60. package/dist/types/context-evidence.d.ts +2 -0
  61. package/dist/types/index.d.ts +2 -2
  62. package/dist/types/index.js +20 -0
  63. package/dist/types/messages.d.ts +8 -0
  64. package/dist/types/multi-agent.d.ts +7 -0
  65. package/dist/types/session.d.ts +19 -0
  66. package/dist/types/task-graph.d.ts +2 -0
  67. package/dist/types/tool-executor.d.ts +2 -0
  68. package/dist/utils/context-evidence.d.ts +13 -1
  69. package/dist/utils/index.js +29 -2
  70. package/instructions/system-lite.md +23 -8
  71. package/instructions/system-pro.md +29 -9
  72. package/instructions/system.md +29 -9
  73. package/package.json +7 -3
  74. package/skills/wrongstack-kanban/SKILL.md +39 -8
@@ -2102,9 +2102,9 @@ async function importLegacyChronicleJournal(journal, directory) {
2102
2102
  }
2103
2103
 
2104
2104
  // src/chronicle/metrics-store.ts
2105
- import * as fs7 from "node:fs/promises";
2106
- import { createRequire } from "node:module";
2107
- import * as path12 from "node:path";
2105
+ import * as fs8 from "node:fs/promises";
2106
+ import { createRequire as createRequire2 } from "node:module";
2107
+ import * as path13 from "node:path";
2108
2108
 
2109
2109
  // src/utils/sqlite-warning.ts
2110
2110
  var SQLITE_EXPERIMENTAL_WARNING_RE = /sqlite is an experimental feature/i;
@@ -2917,1225 +2917,859 @@ function facetValue(event, field) {
2917
2917
  return values[field];
2918
2918
  }
2919
2919
 
2920
- // src/chronicle/metrics-store.ts
2921
- var SCHEMA_VERSION = 3;
2922
- var READ_CHUNK_BYTES = 1024 * 1024;
2923
- var EMPTY_FAMILIES = {
2924
- llm: 0,
2925
- agent: 0,
2926
- tool: 0,
2927
- file: 0,
2928
- memory: 0,
2929
- task: 0,
2930
- decision: 0,
2931
- runtime: 0,
2932
- finding: 0
2933
- };
2934
- var Ctor;
2935
- function loadDatabaseSync() {
2936
- if (Ctor) return Ctor;
2937
- if (Ctor === null) throw new Error("node:sqlite is unavailable in this runtime");
2920
+ // src/chronicle/sqlite-journal.ts
2921
+ import { randomUUID as randomUUID3 } from "node:crypto";
2922
+ import * as fs7 from "node:fs";
2923
+ import { createRequire } from "node:module";
2924
+ import * as path12 from "node:path";
2925
+
2926
+ // src/utils/socket-path.ts
2927
+ import {
2928
+ assertUnixSocketPathWithinLimit,
2929
+ checkUnixSocketPath,
2930
+ unixSocketPathLimit
2931
+ } from "@wrongstack/persistence";
2932
+
2933
+ // src/chronicle/sqlite-query.ts
2934
+ var MAX_LIMIT = 1e4;
2935
+ function encodeCursor2(cursor) {
2936
+ return Buffer.from(`${cursor.day}:${cursor.sequence}`, "utf8").toString("base64url");
2937
+ }
2938
+ function decodeCursor2(raw) {
2939
+ if (!raw) return void 0;
2938
2940
  try {
2939
- Ctor = withSqliteExperimentalWarningSuppressed(
2940
- () => createRequire(import.meta.url)("node:sqlite").DatabaseSync
2941
- );
2942
- return Ctor;
2943
- } catch (error) {
2944
- Ctor = null;
2945
- throw new Error(
2946
- "Chronicle metrics need Node's built-in SQLite (node:sqlite, Node >= 22.5): " + (error instanceof Error ? error.message : String(error))
2947
- );
2941
+ const [day, sequence] = Buffer.from(raw, "base64url").toString("utf8").split(":");
2942
+ if (!day || sequence === void 0) return void 0;
2943
+ const parsed2 = Number(sequence);
2944
+ return Number.isSafeInteger(parsed2) ? { day, sequence: parsed2 } : void 0;
2945
+ } catch {
2946
+ return void 0;
2948
2947
  }
2949
2948
  }
2950
- var ChronicleMetricsStore = class _ChronicleMetricsStore {
2951
- db;
2952
- directory;
2953
- dbPath;
2954
- constructor(directory) {
2955
- this.directory = path12.resolve(directory);
2956
- this.dbPath = path12.join(this.directory, "metrics.db");
2957
- const Database = loadDatabaseSync();
2958
- this.db = new Database(this.dbPath);
2959
- this.db.exec("PRAGMA journal_mode = WAL");
2960
- this.ensureSchema();
2949
+ function pushDown(query) {
2950
+ const clauses = [];
2951
+ const params = [];
2952
+ const eq = (column, value) => {
2953
+ if (value === void 0) return;
2954
+ clauses.push(`${column} = ?`);
2955
+ params.push(value);
2956
+ };
2957
+ eq("event_id", query.eventId);
2958
+ eq("project_id", query.projectId);
2959
+ eq("session_id", query.sessionId);
2960
+ eq("agent_id", query.agentId);
2961
+ eq("task_id", query.taskId);
2962
+ eq("trace_id", query.traceId);
2963
+ eq("logical_request_id", query.logicalRequestId);
2964
+ eq("resource_kind", query.resourceKind);
2965
+ eq("resource_id", query.resourceId);
2966
+ if (query.eventTypes?.length) {
2967
+ clauses.push(`event_type IN (${query.eventTypes.map(() => "?").join(",")})`);
2968
+ params.push(...query.eventTypes);
2961
2969
  }
2962
- static open(chronicleDirectory2) {
2963
- return new _ChronicleMetricsStore(chronicleDirectory2);
2970
+ if (query.outcomes?.length) {
2971
+ clauses.push(`outcome IN (${query.outcomes.map(() => "?").join(",")})`);
2972
+ params.push(...query.outcomes);
2964
2973
  }
2965
- close() {
2966
- this.db.close();
2974
+ if (query.from) {
2975
+ clauses.push("occurred_at >= ?");
2976
+ params.push(query.from);
2967
2977
  }
2968
- /** Incrementally ingest journal bytes appended since the last refresh.
2969
- * Safe across processes: guarded by a file lock on the database path. */
2970
- async refresh() {
2978
+ if (query.to) {
2979
+ clauses.push("occurred_at <= ?");
2980
+ params.push(query.to);
2981
+ }
2982
+ return { clause: clauses.length ? clauses.join(" AND ") : "1=1", params };
2983
+ }
2984
+ var ChronicleSqliteQueryEngine = class {
2985
+ constructor(db, options = {}) {
2986
+ this.db = db;
2987
+ this.batchSize = Math.max(1, options.batchSize ?? 1e3);
2988
+ }
2989
+ db;
2990
+ diagnostics = { sourceFiles: 1, invalidLines: 0 };
2991
+ batchSize;
2992
+ async query(query = {}) {
2993
+ const order = query.order ?? "desc";
2994
+ const limit = Math.max(1, Math.min(query.limit ?? 100, MAX_LIMIT));
2995
+ const cursor = decodeCursor2(query.cursor);
2996
+ const pushed = pushDown(query);
2997
+ const direction = order === "asc" ? "ASC" : "DESC";
2998
+ const comparison = order === "asc" ? ">" : "<";
2999
+ const keyset = cursor ? ` AND (day, sequence) ${comparison} (?, ?)` : "";
3000
+ const sql = `SELECT day, sequence, payload FROM events WHERE ${pushed.clause}${keyset} ORDER BY day ${direction}, sequence ${direction} LIMIT ? OFFSET ?`;
3001
+ const summary = createSummaryAccumulator();
3002
+ const page = [];
3003
+ let total = 0;
3004
+ let scannedEvents = 0;
3005
+ let last;
3006
+ let offset = 0;
3007
+ for (; ; ) {
3008
+ const params = [...pushed.params];
3009
+ if (cursor) params.push(cursor.day, cursor.sequence);
3010
+ params.push(this.batchSize, offset);
3011
+ const rows = this.db.prepare(sql).all(...params);
3012
+ if (rows.length === 0) break;
3013
+ offset += rows.length;
3014
+ for (const row of rows) {
3015
+ scannedEvents++;
3016
+ let event;
3017
+ try {
3018
+ event = JSON.parse(row.payload);
3019
+ } catch {
3020
+ this.diagnostics.invalidLines++;
3021
+ continue;
3022
+ }
3023
+ if (!matches(event, query)) continue;
3024
+ total++;
3025
+ updateSummary(summary, event);
3026
+ if (page.length < limit) {
3027
+ page.push(event);
3028
+ last = { day: row.day, sequence: row.sequence };
3029
+ }
3030
+ }
3031
+ if (rows.length < this.batchSize) break;
3032
+ }
3033
+ page.sort((left, right) => compareEvents(left, right) * (order === "asc" ? 1 : -1));
2971
3034
  const result = {
2972
- ingestedEvents: 0,
2973
- ingestedBytes: 0,
2974
- sourceFiles: 0,
2975
- invalidLines: 0
3035
+ events: page,
3036
+ total,
3037
+ scannedEvents,
3038
+ sourceFiles: this.diagnostics.sourceFiles,
3039
+ invalidLines: this.diagnostics.invalidLines,
3040
+ summary: finalizeSummary(summary)
2976
3041
  };
2977
- await withFileLock(this.dbPath, async () => {
2978
- const files = await findChroniclePartitions(this.directory);
2979
- const offsets = this.loadOffsets();
2980
- for (const file of files) {
2981
- const key = normalizeKey(path12.relative(this.directory, file));
2982
- const consumed = offsets.get(key) ?? 0;
2983
- const ingested = await this.ingestFile(file, key, consumed, result);
2984
- if (ingested) result.sourceFiles++;
2985
- }
2986
- this.pruneOffsets(files);
2987
- });
3042
+ if (total > page.length && last) {
3043
+ return { ...result, nextCursor: encodeCursor2(last) };
3044
+ }
2988
3045
  return result;
2989
3046
  }
2990
- providerDaily(options = {}) {
2991
- const clauses = [];
2992
- const params = [];
2993
- if (options.from) {
2994
- clauses.push("day >= ?");
2995
- params.push(options.from.slice(0, 10));
3047
+ /**
3048
+ * Value counts per facet field.
3049
+ *
3050
+ * Not a SQL `GROUP BY`: `facetValue()` reads fields that live inside the
3051
+ * payload — provider, model, tool call, tag — so grouping in SQL would only
3052
+ * work for the handful that happen to be columns and would need a second,
3053
+ * divergent definition for the rest. The narrowing is still done by SQL; the
3054
+ * counting uses the JSONL engine's own projection.
3055
+ */
3056
+ async facets(fields, query = {}, limit = 100) {
3057
+ const uniqueFields = [...new Set(fields)];
3058
+ if (uniqueFields.length === 0) return {};
3059
+ const counts = new Map(uniqueFields.map((field) => [field, /* @__PURE__ */ new Map()]));
3060
+ for (const event of this.eachMatch(query)) {
3061
+ for (const field of uniqueFields) {
3062
+ const value = facetValue(event, field);
3063
+ if (value === void 0) continue;
3064
+ const fieldCounts = counts.get(field);
3065
+ fieldCounts?.set(value, (fieldCounts.get(value) ?? 0) + 1);
3066
+ }
2996
3067
  }
2997
- if (options.to) {
2998
- clauses.push("day <= ?");
2999
- params.push(options.to.slice(0, 10));
3068
+ const result = {};
3069
+ for (const field of uniqueFields) {
3070
+ result[field] = [...counts.get(field) ?? /* @__PURE__ */ new Map()].map(([value, count]) => ({ value, count })).sort((left, right) => right.count - left.count || left.value.localeCompare(right.value)).slice(0, Math.max(0, limit));
3000
3071
  }
3001
- const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
3002
- const rows = this.db.prepare(
3003
- `SELECT day, provider_id, model_id, attempts, completed, failed, retries, fallbacks,
3004
- input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
3005
- duration_ms_total, duration_ms_max, duration_count
3006
- FROM provider_daily${where} ORDER BY day DESC, provider_id, model_id`
3007
- ).all(...params);
3008
- return rows.map((row) => ({
3009
- day: String(row.day),
3010
- providerId: String(row.provider_id),
3011
- modelId: String(row.model_id),
3012
- attempts: Number(row.attempts),
3013
- completed: Number(row.completed),
3014
- failed: Number(row.failed),
3015
- retries: Number(row.retries),
3016
- fallbacks: Number(row.fallbacks),
3017
- inputTokens: Number(row.input_tokens),
3018
- outputTokens: Number(row.output_tokens),
3019
- cacheReadTokens: Number(row.cache_read_tokens),
3020
- cacheWriteTokens: Number(row.cache_write_tokens),
3021
- avgDurationMs: Number(row.duration_count) > 0 ? Number(row.duration_ms_total) / Number(row.duration_count) : 0,
3022
- maxDurationMs: Number(row.duration_ms_max)
3023
- }));
3072
+ return result;
3024
3073
  }
3025
- taskOutcomes(options = {}) {
3026
- const clauses = [];
3027
- const params = [];
3028
- if (options.runId) {
3029
- clauses.push("t.run_id = ?");
3030
- params.push(options.runId);
3031
- }
3032
- if (options.boardId) {
3033
- clauses.push("t.board_id = ?");
3034
- params.push(options.boardId);
3035
- }
3036
- if (options.sessionId) {
3037
- clauses.push("t.session_id = ?");
3038
- params.push(options.sessionId);
3039
- }
3040
- if (options.status) {
3041
- clauses.push("t.status = ?");
3042
- params.push(options.status);
3043
- }
3044
- const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
3045
- params.push(clampLimit(options.limit, 100));
3046
- const rows = this.db.prepare(
3047
- `SELECT t.*, (SELECT COUNT(*) FROM file_lineage f WHERE f.task_id = t.task_id) AS files_touched
3048
- FROM task_outcomes t${where}
3049
- ORDER BY COALESCE(t.started_at, '') DESC LIMIT ?`
3050
- ).all(...params);
3051
- return rows.map((row) => ({
3052
- taskId: String(row.task_id),
3053
- runId: String(row.run_id),
3054
- boardId: String(row.board_id),
3055
- sessionId: String(row.session_id),
3056
- agentId: String(row.agent_id),
3057
- status: String(row.status),
3058
- startedAt: row.started_at === null ? null : String(row.started_at),
3059
- endedAt: row.ended_at === null ? null : String(row.ended_at),
3060
- durationMs: row.duration_ms === null ? null : Number(row.duration_ms),
3061
- retries: Number(row.retries),
3062
- verificationFailures: Number(row.verification_failures),
3063
- filesTouched: Number(row.files_touched)
3064
- }));
3074
+ async facet(field, query = {}, limit = 100) {
3075
+ return (await this.facets([field], query, limit))[field] ?? [];
3065
3076
  }
3066
- fileLineage(options = {}) {
3067
- const clauses = [];
3068
- const params = [];
3069
- if (options.path) {
3070
- clauses.push("path_key = ?");
3071
- params.push(normalizePathKey(options.path));
3072
- }
3073
- if (options.paths) {
3074
- const pathKeys = [...new Set(options.paths.map(normalizePathKey))];
3075
- if (pathKeys.length === 0) return [];
3076
- clauses.push(`path_key IN (${pathKeys.map(() => "?").join(",")})`);
3077
- params.push(...pathKeys);
3078
- }
3079
- if (options.taskId) {
3080
- clauses.push("task_id = ?");
3081
- params.push(options.taskId);
3077
+ /**
3078
+ * Expand explicit and typed correlation edges around a seed set.
3079
+ *
3080
+ * The traversal is the JSONL engine's, moved onto rows: `relationKeys()`
3081
+ * defines the edges, `compareEvents` orders the nodes, and each hop is
3082
+ * another ordered pass. Order is load-bearing rather than cosmetic — both
3083
+ * `maxNodes` truncations stop at whatever they reach first, so a differently
3084
+ * ordered scan would return a different subgraph rather than the same one
3085
+ * shuffled. `ORDER BY day, sequence` reproduces `comparePartitionPaths`
3086
+ * (family, then rotation index) followed by line order within a partition.
3087
+ */
3088
+ async graph(seed = {}, hops = 2, maxNodes = 1e3) {
3089
+ const nodeLimit = Math.max(0, Math.floor(maxNodes));
3090
+ const selected = /* @__PURE__ */ new Map();
3091
+ let seedCount = 0;
3092
+ for (const event of this.eachMatch(seed)) {
3093
+ seedCount++;
3094
+ if (selected.size < nodeLimit) selected.set(event.eventId, event);
3082
3095
  }
3083
- if (options.boardId) {
3084
- clauses.push("board_id = ?");
3085
- params.push(options.boardId);
3096
+ let frontier = [...selected.values()];
3097
+ const depthLimit = Math.max(0, Math.min(hops, 10));
3098
+ for (let depth = 0; depth < depthLimit && frontier.length > 0 && selected.size < nodeLimit; depth++) {
3099
+ const frontierKeys = new Set(
3100
+ frontier.flatMap((event) => relationKeys(event).map((relation) => relation.key))
3101
+ );
3102
+ const next = [];
3103
+ for (const event of this.eachMatch({})) {
3104
+ if (selected.has(event.eventId)) continue;
3105
+ if (!relationKeys(event).some((relation) => frontierKeys.has(relation.key))) continue;
3106
+ selected.set(event.eventId, event);
3107
+ next.push(event);
3108
+ if (selected.size >= nodeLimit) break;
3109
+ }
3110
+ frontier = next;
3086
3111
  }
3087
- if (options.sessionId) {
3088
- clauses.push("session_id = ?");
3089
- params.push(options.sessionId);
3112
+ const nodes = [...selected.values()].sort(compareEvents);
3113
+ const byKey = /* @__PURE__ */ new Map();
3114
+ for (const node of nodes) {
3115
+ for (const relation of relationKeys(node)) {
3116
+ const related = byKey.get(relation.key) ?? [];
3117
+ related.push(node);
3118
+ byKey.set(relation.key, related);
3119
+ }
3090
3120
  }
3091
- const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
3092
- params.push(clampLimit(options.limit, 200));
3093
- const projection = `path, operation, occurred_at, session_id, agent_id, task_id, board_id, run_id,
3094
- tool_name, provider_id, model_id, source`;
3095
- const sql = options.latestPerPath ? `SELECT ${projection} FROM (
3096
- SELECT ${projection}, ROW_NUMBER() OVER (
3097
- PARTITION BY path_key ORDER BY occurred_at DESC, event_id DESC
3098
- ) AS path_rank
3099
- FROM file_lineage${where}
3100
- ) WHERE path_rank = 1 ORDER BY occurred_at DESC LIMIT ?` : `SELECT ${projection}
3101
- FROM file_lineage${where} ORDER BY occurred_at DESC LIMIT ?`;
3102
- const rows = this.db.prepare(sql).all(...params);
3103
- return rows.map((row) => ({
3104
- path: row.path,
3105
- operation: row.operation,
3106
- occurredAt: row.occurred_at,
3107
- sessionId: row.session_id,
3108
- agentId: row.agent_id,
3109
- taskId: row.task_id,
3110
- boardId: row.board_id,
3111
- runId: row.run_id,
3112
- toolName: row.tool_name,
3113
- providerId: row.provider_id,
3114
- modelId: row.model_id,
3115
- source: row.source
3116
- }));
3117
- }
3118
- summary() {
3119
- const provider = this.db.prepare(
3120
- "SELECT COALESCE(SUM(attempts),0) a, COALESCE(SUM(completed),0) c, COALESCE(SUM(failed),0) f FROM provider_daily"
3121
- ).get();
3122
- const tasks = {};
3123
- for (const row of this.db.prepare("SELECT status, COUNT(*) n FROM task_outcomes GROUP BY status").all()) {
3124
- tasks[row.status] = Number(row.n);
3121
+ const edges = [];
3122
+ const seen = /* @__PURE__ */ new Set();
3123
+ for (const node of nodes) {
3124
+ for (const relation of relationKeys(node)) {
3125
+ for (const candidate of byKey.get(relation.key) ?? []) {
3126
+ if (candidate.eventId === node.eventId) continue;
3127
+ const [from, to] = compareEvents(node, candidate) <= 0 ? [node, candidate] : [candidate, node];
3128
+ const id = `${from.eventId}:${to.eventId}:${relation.kind}`;
3129
+ if (seen.has(id)) continue;
3130
+ seen.add(id);
3131
+ edges.push({
3132
+ from: from.eventId,
3133
+ to: to.eventId,
3134
+ kind: relation.kind,
3135
+ confidence: relation.confidence
3136
+ });
3137
+ }
3138
+ }
3125
3139
  }
3126
- const files = this.db.prepare("SELECT COUNT(*) n, COUNT(DISTINCT path) p FROM file_lineage").get();
3127
- const cost = this.db.prepare("SELECT COALESCE(SUM(cost),0) c FROM token_cost").get();
3128
- const terminal = Number(provider.c) + Number(provider.f);
3129
3140
  return {
3130
- providers: {
3131
- attempts: Number(provider.a),
3132
- completed: Number(provider.c),
3133
- failed: Number(provider.f),
3134
- successRate: terminal > 0 ? Number(provider.c) / terminal : 0
3135
- },
3136
- tasks,
3137
- files: { mutations: Number(files.n), uniquePaths: Number(files.p) },
3138
- estimatedCostUsd: Number(cost.c)
3141
+ nodes,
3142
+ edges,
3143
+ truncated: seedCount > nodeLimit || selected.size >= nodeLimit
3139
3144
  };
3140
3145
  }
3141
- /**
3142
- * A `ChronicleSummary` for the default/unfiltered dashboard view — only
3143
- * `from`/`to` (day-precision) narrow it. Any other ad hoc filter (text,
3144
- * path, provider, model, session) can't be answered from these
3145
- * fixed-dimension aggregates; callers must fall back to query.ts's
3146
- * raw-scan summary for those.
3147
- */
3148
- defaultSummary(options = {}) {
3149
- const fromDay = options.from?.slice(0, 10);
3150
- const toDay = options.to?.slice(0, 10);
3151
- const dayFilter = (column) => {
3152
- const clauses = [];
3153
- const params = [];
3154
- if (fromDay) {
3155
- clauses.push(`${column} >= ?`);
3156
- params.push(fromDay);
3157
- }
3158
- if (toDay) {
3159
- clauses.push(`${column} <= ?`);
3160
- params.push(toDay);
3146
+ /** Every event satisfying `query`, pulled in batches so a wide scan stays bounded. */
3147
+ *eachMatch(query) {
3148
+ const pushed = pushDown(query);
3149
+ const sql = `SELECT payload FROM events WHERE ${pushed.clause} ORDER BY day, sequence LIMIT ? OFFSET ?`;
3150
+ let offset = 0;
3151
+ for (; ; ) {
3152
+ const rows = this.db.prepare(sql).all(...pushed.params, this.batchSize, offset);
3153
+ if (rows.length === 0) return;
3154
+ offset += rows.length;
3155
+ for (const row of rows) {
3156
+ let event;
3157
+ try {
3158
+ event = JSON.parse(row.payload);
3159
+ } catch {
3160
+ this.diagnostics.invalidLines++;
3161
+ continue;
3162
+ }
3163
+ if (matches(event, query)) yield event;
3161
3164
  }
3162
- return { where: clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "", params };
3163
- };
3164
- const providerRange = dayFilter("day");
3165
- const provider = this.db.prepare(
3166
- `SELECT COALESCE(SUM(attempts),0) attempts, COALESCE(SUM(completed),0) completed, COALESCE(SUM(failed),0) failed,
3167
- COALESCE(SUM(retries),0) retries, COALESCE(SUM(fallbacks),0) fallbacks,
3168
- COUNT(DISTINCT provider_id) providers, COUNT(DISTINCT model_id) models,
3169
- COALESCE(SUM(input_tokens),0) inputTokens, COALESCE(SUM(output_tokens),0) outputTokens,
3170
- COALESCE(SUM(cache_read_tokens),0) cacheReadTokens, COALESCE(SUM(cache_write_tokens),0) cacheWriteTokens,
3171
- COALESCE(SUM(duration_ms_total),0) durationTotal, COALESCE(MAX(duration_ms_max),0) durationMax,
3172
- COALESCE(SUM(duration_count),0) durationCount
3173
- FROM provider_daily${providerRange.where}`
3174
- ).get(...providerRange.params);
3175
- const counterRange = dayFilter("day");
3176
- const counters = this.db.prepare(
3177
- `SELECT COALESCE(SUM(tool_calls),0) toolCalls, COALESCE(SUM(completed_tools),0) completedTools,
3178
- COALESCE(SUM(failed_tools),0) failedTools, COALESCE(SUM(tool_duration_ms_total),0) toolDurationTotal,
3179
- COALESCE(SUM(tool_duration_count),0) toolDurationCount, COALESCE(SUM(processes),0) processes,
3180
- COALESCE(SUM(failed_processes),0) failedProcesses, COALESCE(SUM(file_events_all),0) fileEvents,
3181
- COALESCE(SUM(decisions),0) decisions, COALESCE(SUM(escalations),0) escalations,
3182
- COALESCE(SUM(agent_events),0) agentEvents, COALESCE(SUM(failures),0) failures,
3183
- COALESCE(SUM(cancellations),0) cancellations
3184
- FROM daily_counters${counterRange.where}`
3185
- ).get(...counterRange.params);
3186
- const familyRange = dayFilter("day");
3187
- const familyRows = this.db.prepare(`SELECT family, count, failure_count FROM family_daily${familyRange.where}`).all(...familyRange.params);
3188
- const families = { ...EMPTY_FAMILIES };
3189
- const failuresByFamily = { ...EMPTY_FAMILIES };
3190
- for (const row of familyRows) {
3191
- const family = row.family;
3192
- families[family] = Number(row.count);
3193
- failuresByFamily[family] = Number(row.failure_count);
3165
+ if (rows.length < this.batchSize) return;
3194
3166
  }
3195
- const agentRange = dayFilter("day");
3196
- const uniqueAgents = this.db.prepare(`SELECT COUNT(DISTINCT agent_id) n FROM agent_daily${agentRange.where}`).get(...agentRange.params).n;
3197
- const requestRange = dayFilter("day");
3198
- const logicalRequests = this.db.prepare(`SELECT COUNT(DISTINCT logical_request_id) n FROM logical_request_daily${requestRange.where}`).get(...requestRange.params).n;
3199
- const fileRange = dayFilter("day");
3200
- const uniqueFiles = this.db.prepare(`SELECT COUNT(DISTINCT path_key) n FROM file_seen_daily${fileRange.where}`).get(...fileRange.params).n;
3201
- const costRange = dayFilter("day");
3202
- const cost = this.db.prepare(`SELECT COALESCE(SUM(cost),0) c FROM token_cost${costRange.where}`).get(...costRange.params).c;
3203
- return {
3204
- logicalRequests: Number(logicalRequests),
3205
- modelAttempts: Number(provider.attempts),
3206
- completedAttempts: Number(provider.completed),
3207
- failedAttempts: Number(provider.failed),
3208
- scheduledRetries: Number(provider.retries),
3209
- fallbacks: Number(provider.fallbacks),
3210
- providers: Number(provider.providers),
3211
- models: Number(provider.models),
3212
- inputTokens: Number(provider.inputTokens),
3213
- outputTokens: Number(provider.outputTokens),
3214
- cacheReadTokens: Number(provider.cacheReadTokens),
3215
- cacheWriteTokens: Number(provider.cacheWriteTokens),
3216
- estimatedCostUsd: Number(cost),
3217
- providerAvgDurationMs: Number(provider.durationCount) > 0 ? Number(provider.durationTotal) / Number(provider.durationCount) : 0,
3218
- // True p95 needs a retained distribution; this per-day aggregate only
3219
- // keeps sum/max/count, so approximate with the observed max rather
3220
- // than adding a per-attempt histogram write (would add the same kind
3221
- // of per-event overhead this whole effort is trying to remove).
3222
- providerP95DurationMs: Number(provider.durationMax),
3223
- toolCalls: Number(counters.toolCalls),
3224
- completedTools: Number(counters.completedTools),
3225
- failedTools: Number(counters.failedTools),
3226
- toolAvgDurationMs: Number(counters.toolDurationCount) > 0 ? Number(counters.toolDurationTotal) / Number(counters.toolDurationCount) : 0,
3227
- processes: Number(counters.processes),
3228
- failedProcesses: Number(counters.failedProcesses),
3229
- fileEvents: Number(counters.fileEvents),
3230
- uniqueFiles: Number(uniqueFiles),
3231
- agentEvents: Number(counters.agentEvents),
3232
- uniqueAgents: Number(uniqueAgents),
3233
- decisions: Number(counters.decisions),
3234
- escalations: Number(counters.escalations),
3235
- failures: Number(counters.failures),
3236
- cancellations: Number(counters.cancellations),
3237
- families,
3238
- failuresByFamily
3239
- };
3240
- }
3241
- // ─── Ingest internals ─────────────────────────────────────────────────────
3242
- ensureSchema() {
3243
- const version = this.db.prepare("PRAGMA user_version").get().user_version;
3244
- if (version !== 0 && version !== SCHEMA_VERSION) {
3245
- this.db.exec(
3246
- "DROP TABLE IF EXISTS ingest_state; DROP TABLE IF EXISTS provider_daily;DROP TABLE IF EXISTS task_outcomes; DROP TABLE IF EXISTS file_lineage;DROP TABLE IF EXISTS token_cost; DROP TABLE IF EXISTS daily_counters;DROP TABLE IF EXISTS family_daily; DROP TABLE IF EXISTS agent_daily;DROP TABLE IF EXISTS logical_request_daily; DROP TABLE IF EXISTS file_seen_daily;"
3247
- );
3248
- }
3249
- this.db.exec(`
3250
- CREATE TABLE IF NOT EXISTS ingest_state (
3251
- file TEXT PRIMARY KEY,
3252
- bytes INTEGER NOT NULL
3253
- );
3254
- CREATE TABLE IF NOT EXISTS provider_daily (
3255
- day TEXT NOT NULL,
3256
- provider_id TEXT NOT NULL,
3257
- model_id TEXT NOT NULL,
3258
- attempts INTEGER NOT NULL DEFAULT 0,
3259
- completed INTEGER NOT NULL DEFAULT 0,
3260
- failed INTEGER NOT NULL DEFAULT 0,
3261
- retries INTEGER NOT NULL DEFAULT 0,
3262
- fallbacks INTEGER NOT NULL DEFAULT 0,
3263
- input_tokens INTEGER NOT NULL DEFAULT 0,
3264
- output_tokens INTEGER NOT NULL DEFAULT 0,
3265
- cache_read_tokens INTEGER NOT NULL DEFAULT 0,
3266
- cache_write_tokens INTEGER NOT NULL DEFAULT 0,
3267
- duration_ms_total REAL NOT NULL DEFAULT 0,
3268
- duration_ms_max REAL NOT NULL DEFAULT 0,
3269
- duration_count INTEGER NOT NULL DEFAULT 0,
3270
- PRIMARY KEY (day, provider_id, model_id)
3271
- );
3272
- CREATE TABLE IF NOT EXISTS task_outcomes (
3273
- task_id TEXT PRIMARY KEY,
3274
- run_id TEXT NOT NULL DEFAULT '',
3275
- board_id TEXT NOT NULL DEFAULT '',
3276
- session_id TEXT NOT NULL DEFAULT '',
3277
- agent_id TEXT NOT NULL DEFAULT '',
3278
- status TEXT NOT NULL DEFAULT 'started',
3279
- started_at TEXT,
3280
- ended_at TEXT,
3281
- duration_ms REAL,
3282
- retries INTEGER NOT NULL DEFAULT 0,
3283
- verification_failures INTEGER NOT NULL DEFAULT 0
3284
- );
3285
- CREATE TABLE IF NOT EXISTS file_lineage (
3286
- event_id TEXT PRIMARY KEY,
3287
- path TEXT NOT NULL,
3288
- path_key TEXT NOT NULL,
3289
- operation TEXT NOT NULL,
3290
- occurred_at TEXT NOT NULL,
3291
- session_id TEXT NOT NULL DEFAULT '',
3292
- agent_id TEXT NOT NULL DEFAULT '',
3293
- task_id TEXT NOT NULL DEFAULT '',
3294
- board_id TEXT NOT NULL DEFAULT '',
3295
- run_id TEXT NOT NULL DEFAULT '',
3296
- tool_name TEXT NOT NULL DEFAULT '',
3297
- provider_id TEXT NOT NULL DEFAULT '',
3298
- model_id TEXT NOT NULL DEFAULT '',
3299
- source TEXT NOT NULL DEFAULT ''
3300
- );
3301
- -- Lookups filter on the case-normalized path_key (matching the query
3302
- -- engine); the path column retains original casing for display.
3303
- CREATE INDEX IF NOT EXISTS idx_file_lineage_path ON file_lineage(path_key, occurred_at);
3304
- CREATE INDEX IF NOT EXISTS idx_file_lineage_task ON file_lineage(task_id);
3305
- CREATE TABLE IF NOT EXISTS token_cost (
3306
- scope_key TEXT PRIMARY KEY,
3307
- day TEXT NOT NULL,
3308
- occurred_at TEXT NOT NULL,
3309
- sequence INTEGER NOT NULL,
3310
- cost REAL NOT NULL
3311
- );
3312
- -- Backing store for defaultSummary(): per-day scalar counters plus
3313
- -- dedup sets, populated for every ingested event (not just the
3314
- -- provider/task/file families above).
3315
- CREATE TABLE IF NOT EXISTS daily_counters (
3316
- day TEXT PRIMARY KEY,
3317
- tool_calls INTEGER NOT NULL DEFAULT 0,
3318
- completed_tools INTEGER NOT NULL DEFAULT 0,
3319
- failed_tools INTEGER NOT NULL DEFAULT 0,
3320
- tool_duration_ms_total REAL NOT NULL DEFAULT 0,
3321
- tool_duration_ms_max REAL NOT NULL DEFAULT 0,
3322
- tool_duration_count INTEGER NOT NULL DEFAULT 0,
3323
- processes INTEGER NOT NULL DEFAULT 0,
3324
- failed_processes INTEGER NOT NULL DEFAULT 0,
3325
- file_events_all INTEGER NOT NULL DEFAULT 0,
3326
- decisions INTEGER NOT NULL DEFAULT 0,
3327
- escalations INTEGER NOT NULL DEFAULT 0,
3328
- agent_events INTEGER NOT NULL DEFAULT 0,
3329
- failures INTEGER NOT NULL DEFAULT 0,
3330
- cancellations INTEGER NOT NULL DEFAULT 0
3331
- );
3332
- CREATE TABLE IF NOT EXISTS family_daily (
3333
- day TEXT NOT NULL,
3334
- family TEXT NOT NULL,
3335
- count INTEGER NOT NULL DEFAULT 0,
3336
- failure_count INTEGER NOT NULL DEFAULT 0,
3337
- PRIMARY KEY (day, family)
3338
- );
3339
- CREATE TABLE IF NOT EXISTS agent_daily (day TEXT NOT NULL, agent_id TEXT NOT NULL, PRIMARY KEY (day, agent_id));
3340
- CREATE TABLE IF NOT EXISTS logical_request_daily (day TEXT NOT NULL, logical_request_id TEXT NOT NULL, PRIMARY KEY (day, logical_request_id));
3341
- CREATE TABLE IF NOT EXISTS file_seen_daily (day TEXT NOT NULL, path_key TEXT NOT NULL, PRIMARY KEY (day, path_key));
3342
- PRAGMA user_version = ${SCHEMA_VERSION};
3343
- `);
3344
- }
3345
- loadOffsets() {
3346
- const rows = this.db.prepare("SELECT file, bytes FROM ingest_state").all();
3347
- return new Map(rows.map((row) => [row.file, Number(row.bytes)]));
3348
- }
3349
- pruneOffsets(existingFiles) {
3350
- const keep = new Set(
3351
- existingFiles.map((file) => normalizeKey(path12.relative(this.directory, file)))
3352
- );
3353
- for (const row of this.db.prepare("SELECT file FROM ingest_state").all()) {
3354
- if (!keep.has(row.file))
3355
- this.db.prepare("DELETE FROM ingest_state WHERE file = ?").run(row.file);
3356
- }
3357
- }
3358
- /** Read complete lines appended after `consumed` bytes. The trailing
3359
- * partial line of an actively-written partition is left for the next
3360
- * refresh — `ingest_state.bytes` only ever advances past full lines. */
3361
- async ingestFile(file, key, consumed, result) {
3362
- let handle;
3363
- try {
3364
- handle = await fs7.open(file, "r");
3365
- } catch {
3366
- return false;
3367
- }
3368
- try {
3369
- const size = (await handle.stat()).size;
3370
- if (size <= consumed) return false;
3371
- let position = consumed;
3372
- let remainder = Buffer.alloc(0);
3373
- let advanced = consumed;
3374
- this.db.exec("BEGIN");
3375
- try {
3376
- while (position < size) {
3377
- const length = Math.min(READ_CHUNK_BYTES, size - position);
3378
- const buffer = Buffer.allocUnsafe(length);
3379
- const { bytesRead } = await handle.read(buffer, 0, length, position);
3380
- if (bytesRead <= 0) break;
3381
- position += bytesRead;
3382
- const data = remainder.length > 0 ? Buffer.concat([remainder, buffer.subarray(0, bytesRead)]) : buffer.subarray(0, bytesRead);
3383
- const lastNewline = data.lastIndexOf(10);
3384
- if (lastNewline < 0) {
3385
- remainder = Buffer.from(data);
3386
- continue;
3387
- }
3388
- for (const line of data.subarray(0, lastNewline).toString("utf8").split("\n")) {
3389
- const trimmed = line.trim();
3390
- if (!trimmed) continue;
3391
- try {
3392
- this.ingestEvent(JSON.parse(trimmed));
3393
- result.ingestedEvents++;
3394
- } catch {
3395
- result.invalidLines++;
3396
- }
3397
- }
3398
- advanced += lastNewline + 1;
3399
- remainder = Buffer.from(data.subarray(lastNewline + 1));
3400
- }
3401
- this.db.prepare(
3402
- "INSERT INTO ingest_state (file, bytes) VALUES (?, ?) ON CONFLICT(file) DO UPDATE SET bytes = excluded.bytes"
3403
- ).run(key, advanced);
3404
- this.db.exec("COMMIT");
3405
- } catch (error) {
3406
- this.db.exec("ROLLBACK");
3407
- throw error;
3408
- }
3409
- result.ingestedBytes += advanced - consumed;
3410
- return advanced > consumed;
3411
- } finally {
3412
- await handle.close();
3413
- }
3414
- }
3415
- ingestEvent(event) {
3416
- if (typeof event?.eventType !== "string" || !event.scope) return;
3417
- this.ingestDailyCounters(event);
3418
- const type = event.eventType;
3419
- if (type.startsWith("provider.attempt.") || type === "provider.fallback") {
3420
- this.ingestProvider(event);
3421
- } else if (type === "token.accounted") {
3422
- this.ingestTokenCost(event);
3423
- } else if (/^(?:sdd|subagent|kanban)\.task[._]/.test(type)) {
3424
- this.ingestTask(event);
3425
- } else if (type === "file.event" || /^file\.(?:tool|external)\./.test(type)) {
3426
- this.ingestFileEvent(event);
3427
- }
3428
- }
3429
- /** Runs for every ingested event (not just the type-specific branches
3430
- * below) — mirrors query.ts's updateSummary() closely enough that
3431
- * defaultSummary() matches what a raw scan of the same window would say. */
3432
- ingestDailyCounters(event) {
3433
- const day = eventDay(event);
3434
- this.db.prepare("INSERT OR IGNORE INTO daily_counters (day) VALUES (?)").run(day);
3435
- const bump = (sql, ...params) => this.db.prepare(`UPDATE daily_counters SET ${sql} WHERE day = ?`).run(...params, day);
3436
- const family = signalFamily(event);
3437
- const failed = isTerminalFailure(event) ? 1 : 0;
3438
- this.db.prepare(
3439
- `INSERT INTO family_daily (day, family, count, failure_count) VALUES (?, ?, 1, ?)
3440
- ON CONFLICT(day, family) DO UPDATE SET count = count + 1, failure_count = failure_count + excluded.failure_count`
3441
- ).run(day, family, failed);
3442
- if (failed) bump("failures = failures + 1");
3443
- if (event.outcome === "cancelled" || event.outcome === "abandoned") bump("cancellations = cancellations + 1");
3444
- if (family === "agent") bump("agent_events = agent_events + 1");
3445
- if (event.correlation.logicalRequestId) {
3446
- this.db.prepare("INSERT OR IGNORE INTO logical_request_daily (day, logical_request_id) VALUES (?, ?)").run(day, event.correlation.logicalRequestId);
3447
- }
3448
- if (event.scope.agentId) {
3449
- this.db.prepare("INSERT OR IGNORE INTO agent_daily (day, agent_id) VALUES (?, ?)").run(day, event.scope.agentId);
3450
- }
3451
- const type = event.eventType;
3452
- if (type === "decision.requested") bump("decisions = decisions + 1");
3453
- else if (type === "decision.escalated") bump("escalations = escalations + 1");
3454
- else if (type === "tool.started") bump("tool_calls = tool_calls + 1");
3455
- else if (type === "tool.executed" || type === "tool.failed") {
3456
- const dur = durationMs2(event);
3457
- const durationCount = dur > 0 ? 1 : 0;
3458
- bump(
3459
- `${type === "tool.executed" ? "completed_tools" : "failed_tools"} = ${type === "tool.executed" ? "completed_tools" : "failed_tools"} + 1,
3460
- tool_duration_ms_total = tool_duration_ms_total + ?, tool_duration_ms_max = MAX(tool_duration_ms_max, ?), tool_duration_count = tool_duration_count + ?`,
3461
- dur,
3462
- dur,
3463
- durationCount
3464
- );
3465
- } else if (type === "process.started") bump("processes = processes + 1");
3466
- else if (type === "process.completed" && event.outcome === "failure") bump("failed_processes = failed_processes + 1");
3467
- if (event.resource?.kind === "file" || type.startsWith("file.")) {
3468
- bump("file_events_all = file_events_all + 1");
3469
- if (event.resource?.path) {
3470
- this.db.prepare("INSERT OR IGNORE INTO file_seen_daily (day, path_key) VALUES (?, ?)").run(day, normalizePathKey(event.resource.path));
3471
- }
3472
- }
3473
- }
3474
- ingestProvider(event) {
3475
- const day = eventDay(event);
3476
- const providerId = event.runtime?.providerId ?? asString(readPath2(event.attributes ?? {}, "from.providerId")) ?? "";
3477
- const modelId = event.runtime?.modelId ?? asString(readPath2(event.attributes ?? {}, "from.model")) ?? "";
3478
- if (!providerId && !modelId) return;
3479
- this.db.prepare("INSERT OR IGNORE INTO provider_daily (day, provider_id, model_id) VALUES (?, ?, ?)").run(day, providerId, modelId);
3480
- const update = (sql, ...params) => this.db.prepare(
3481
- `UPDATE provider_daily SET ${sql} WHERE day = ? AND provider_id = ? AND model_id = ?`
3482
- ).run(...params, day, providerId, modelId);
3483
- const duration = durationMs2(event);
3484
- switch (event.eventType) {
3485
- case "provider.attempt.started":
3486
- update("attempts = attempts + 1");
3487
- break;
3488
- case "provider.attempt.completed":
3489
- update(
3490
- "completed = completed + 1, input_tokens = input_tokens + ?, output_tokens = output_tokens + ?, cache_read_tokens = cache_read_tokens + ?, cache_write_tokens = cache_write_tokens + ?, duration_ms_total = duration_ms_total + ?, duration_ms_max = MAX(duration_ms_max, ?), duration_count = duration_count + ?",
3491
- numberAt2(event, "usage.input"),
3492
- numberAt2(event, "usage.output"),
3493
- numberAt2(event, "usage.cacheRead"),
3494
- numberAt2(event, "usage.cacheWrite"),
3495
- duration,
3496
- duration,
3497
- duration > 0 ? 1 : 0
3498
- );
3499
- break;
3500
- case "provider.attempt.failed":
3501
- update(
3502
- "failed = failed + 1, retries = retries + ?, duration_ms_total = duration_ms_total + ?, duration_ms_max = MAX(duration_ms_max, ?), duration_count = duration_count + ?",
3503
- event.attributes?.retryScheduled === true ? 1 : 0,
3504
- duration,
3505
- duration,
3506
- duration > 0 ? 1 : 0
3507
- );
3508
- break;
3509
- case "provider.fallback":
3510
- update("fallbacks = fallbacks + 1");
3511
- break;
3512
- default:
3513
- break;
3514
- }
3515
- }
3516
- ingestTokenCost(event) {
3517
- const cost = readPath2(event.attributes ?? {}, "cost.total");
3518
- if (typeof cost !== "number" || !Number.isFinite(cost)) return;
3519
- const scopeKey2 = `${event.scope.projectId ?? ""}\0${event.scope.sessionId ?? ""}\0${event.scope.agentId ?? ""}`;
3520
- const occurredAt = event.occurredAt ?? event.observedAt;
3521
- this.db.prepare(
3522
- `INSERT INTO token_cost (scope_key, day, occurred_at, sequence, cost) VALUES (?, ?, ?, ?, ?)
3523
- ON CONFLICT(scope_key) DO UPDATE SET
3524
- day = excluded.day, occurred_at = excluded.occurred_at,
3525
- sequence = excluded.sequence, cost = excluded.cost
3526
- WHERE excluded.occurred_at > token_cost.occurred_at
3527
- OR (excluded.occurred_at = token_cost.occurred_at AND excluded.sequence > token_cost.sequence)`
3528
- ).run(scopeKey2, eventDay(event), occurredAt, event.sequence, cost);
3529
- }
3530
- ingestTask(event) {
3531
- const attributes = event.attributes ?? {};
3532
- const taskId = event.scope.taskId ?? stringAt(attributes, "taskId");
3533
- if (!taskId) return;
3534
- const occurredAt = event.occurredAt ?? event.observedAt;
3535
- this.db.prepare("INSERT OR IGNORE INTO task_outcomes (task_id) VALUES (?)").run(taskId);
3536
- const set = (sql, ...params) => this.db.prepare(`UPDATE task_outcomes SET ${sql} WHERE task_id = ?`).run(...params, taskId);
3537
- const lineage = [
3538
- ["run_id", stringAt(attributes, "runId")],
3539
- ["board_id", event.scope.kanbanBoardId ?? stringAt(attributes, "boardId")],
3540
- ["session_id", event.scope.sessionId],
3541
- ["agent_id", event.scope.agentId ?? stringAt(attributes, "subagentId")]
3542
- ];
3543
- for (const [column, value] of lineage) {
3544
- if (value) set(`${column} = ?`, value);
3545
- }
3546
- const base = event.eventType.replace(/^(?:sdd|subagent|kanban)\.task[._]/, "");
3547
- switch (base) {
3548
- case "started":
3549
- set("status = 'started', started_at = COALESCE(started_at, ?)", occurredAt);
3550
- break;
3551
- case "completed":
3552
- set(
3553
- "status = 'completed', ended_at = ?, duration_ms = ?",
3554
- occurredAt,
3555
- numberOrDuration(event, attributes)
3556
- );
3557
- break;
3558
- case "failed":
3559
- set("status = 'failed', ended_at = ?", occurredAt);
3560
- break;
3561
- case "retrying":
3562
- set("retries = retries + 1");
3563
- break;
3564
- case "verification_failed":
3565
- set("verification_failures = verification_failures + 1");
3566
- break;
3567
- case "merged":
3568
- set("status = 'merged'");
3569
- break;
3570
- case "conflict":
3571
- set("status = 'conflict'");
3572
- break;
3573
- default:
3574
- break;
3575
- }
3576
- }
3577
- ingestFileEvent(event) {
3578
- const attributes = event.attributes ?? {};
3579
- const operation = stringAt(attributes, "operation") ?? "";
3580
- if (!operation || operation === "read") return;
3581
- const filePath = event.resource?.path ?? stringAt(attributes, "filePath");
3582
- if (!filePath) return;
3583
- this.db.prepare(
3584
- `INSERT OR IGNORE INTO file_lineage
3585
- (event_id, path, path_key, operation, occurred_at, session_id, agent_id, task_id, board_id, run_id,
3586
- tool_name, provider_id, model_id, source)
3587
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
3588
- ).run(
3589
- event.eventId,
3590
- normalizeKey(filePath),
3591
- normalizePathKey(filePath),
3592
- operation,
3593
- event.occurredAt ?? event.observedAt,
3594
- event.scope.sessionId ?? "",
3595
- event.scope.agentId ?? "",
3596
- event.scope.taskId ?? stringAt(attributes, "taskId") ?? "",
3597
- event.scope.kanbanBoardId ?? stringAt(attributes, "boardId") ?? "",
3598
- stringAt(attributes, "runId") ?? "",
3599
- stringAt(attributes, "toolName") ?? "",
3600
- event.runtime?.providerId ?? stringAt(attributes, "provider") ?? "",
3601
- event.runtime?.modelId ?? stringAt(attributes, "model") ?? "",
3602
- stringAt(attributes, "source") ?? (event.eventType === "file.event" ? "tool" : "external")
3603
- );
3604
3167
  }
3605
3168
  };
3606
- function eventDay(event) {
3607
- return (event.occurredAt ?? event.observedAt).slice(0, 10);
3608
- }
3609
- function durationMs2(event) {
3610
- const value = Number(event.durationNs ?? 0) / 1e6;
3611
- return Number.isFinite(value) && value > 0 ? value : 0;
3612
- }
3613
- function numberOrDuration(event, attributes) {
3614
- const explicit = attributes.durationMs;
3615
- if (typeof explicit === "number" && Number.isFinite(explicit)) return explicit;
3616
- return durationMs2(event);
3617
- }
3618
- function numberAt2(event, dotPath) {
3619
- const value = readPath2(event.attributes ?? {}, dotPath);
3620
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
3621
- }
3622
- function readPath2(value, key) {
3623
- return key.split(".").reduce(
3624
- (current, part) => current && typeof current === "object" ? current[part] : void 0,
3625
- value
3626
- );
3627
- }
3628
- function stringAt(record, key) {
3629
- const value = record[key];
3630
- return typeof value === "string" && value.length > 0 ? value : void 0;
3631
- }
3632
- function asString(value) {
3633
- return typeof value === "string" && value.length > 0 ? value : void 0;
3634
- }
3635
- function clampLimit(limit, fallback) {
3636
- if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0) return fallback;
3637
- return Math.min(Math.floor(limit), 1e4);
3638
- }
3639
- function normalizeKey(value) {
3640
- return value.replaceAll("\\", "/");
3641
- }
3642
- function normalizePathKey(value) {
3643
- return value.replaceAll("\\", "/").replace(/^\.\//, "").toLowerCase();
3644
- }
3645
-
3646
- // src/chronicle/project-server-endpoint.ts
3647
- import { createHash as createHash5 } from "node:crypto";
3648
- import * as fs8 from "node:fs";
3649
- import * as os3 from "node:os";
3650
- import * as path13 from "node:path";
3651
-
3652
- // src/utils/socket-path.ts
3653
- import {
3654
- assertUnixSocketPathWithinLimit,
3655
- checkUnixSocketPath,
3656
- unixSocketPathLimit
3657
- } from "@wrongstack/persistence";
3658
-
3659
- // src/chronicle/project-server-protocol.ts
3660
- var CHRONICLE_PROJECT_SERVER_PROTOCOL_VERSION = 2;
3661
- var CHRONICLE_PROJECT_SERVER_MAX_FRAME_CHARS = 64 * 1024 * 1024;
3662
- function encodeChronicleProjectServerMessage(message) {
3663
- return `${JSON.stringify(message)}
3664
- `;
3665
- }
3666
-
3667
- // src/chronicle/project-server-endpoint.ts
3668
- var CHRONICLE_PROJECT_SERVER_METADATA_FILE = "server.json";
3669
- function normalizedPath(value) {
3670
- const resolved = path13.resolve(value);
3671
- return process.platform === "win32" ? resolved.toLowerCase() : resolved;
3672
- }
3673
- function chronicleProjectServerKey(projectDir) {
3674
- return createHash5("sha256").update(normalizedPath(path13.join(projectDir, "chronicle"))).digest("hex").slice(0, 24);
3675
- }
3676
- function chronicleProjectServerEndpoint(projectDir) {
3677
- const key = chronicleProjectServerKey(projectDir);
3678
- if (process.platform === "win32") {
3679
- return `\\\\.\\pipe\\wrongstack-chronicle-v${CHRONICLE_PROJECT_SERVER_PROTOCOL_VERSION}-${key}`;
3680
- }
3681
- return path13.join(
3682
- os3.tmpdir(),
3683
- `wsch-v${CHRONICLE_PROJECT_SERVER_PROTOCOL_VERSION}`,
3684
- `${key}.sock`
3685
- );
3686
- }
3687
- function chronicleProjectServerMetadataPath(projectDir) {
3688
- return path13.join(projectDir, "chronicle", CHRONICLE_PROJECT_SERVER_METADATA_FILE);
3689
- }
3690
- function ensureChronicleProjectServerSocketDirectory(endpoint2) {
3691
- if (process.platform !== "win32") {
3692
- assertUnixSocketPathWithinLimit(endpoint2, "chronicle");
3693
- fs8.mkdirSync(path13.dirname(endpoint2), { recursive: true, mode: 448 });
3694
- }
3695
- }
3696
3169
 
3697
3170
  // src/chronicle/sqlite-journal.ts
3698
- import { randomUUID as randomUUID3 } from "node:crypto";
3699
- import * as fs9 from "node:fs";
3700
- import { createRequire as createRequire2 } from "node:module";
3701
- import * as path14 from "node:path";
3702
-
3703
- // src/chronicle/sqlite-query.ts
3704
- var MAX_LIMIT = 1e4;
3705
- function encodeCursor2(cursor) {
3706
- return Buffer.from(`${cursor.day}:${cursor.sequence}`, "utf8").toString("base64url");
3707
- }
3708
- function decodeCursor2(raw) {
3709
- if (!raw) return void 0;
3710
- try {
3711
- const [day, sequence] = Buffer.from(raw, "base64url").toString("utf8").split(":");
3712
- if (!day || sequence === void 0) return void 0;
3713
- const parsed2 = Number(sequence);
3714
- return Number.isSafeInteger(parsed2) ? { day, sequence: parsed2 } : void 0;
3715
- } catch {
3716
- return void 0;
3717
- }
3718
- }
3719
- function pushDown(query) {
3720
- const clauses = [];
3721
- const params = [];
3722
- const eq = (column, value) => {
3723
- if (value === void 0) return;
3724
- clauses.push(`${column} = ?`);
3725
- params.push(value);
3726
- };
3727
- eq("event_id", query.eventId);
3728
- eq("project_id", query.projectId);
3729
- eq("session_id", query.sessionId);
3730
- eq("agent_id", query.agentId);
3731
- eq("task_id", query.taskId);
3732
- eq("trace_id", query.traceId);
3733
- eq("logical_request_id", query.logicalRequestId);
3734
- eq("resource_kind", query.resourceKind);
3735
- eq("resource_id", query.resourceId);
3736
- if (query.eventTypes?.length) {
3737
- clauses.push(`event_type IN (${query.eventTypes.map(() => "?").join(",")})`);
3738
- params.push(...query.eventTypes);
3739
- }
3740
- if (query.outcomes?.length) {
3741
- clauses.push(`outcome IN (${query.outcomes.map(() => "?").join(",")})`);
3742
- params.push(...query.outcomes);
3743
- }
3744
- if (query.from) {
3745
- clauses.push("occurred_at >= ?");
3746
- params.push(query.from);
3171
+ var CHRONICLE_SQLITE_FILE = "chronicle.sqlite";
3172
+ var LEGACY_JSONL_MIGRATION_KEY = "legacy-jsonl-v1";
3173
+ var LEGACY_JSONL_QUARANTINE_KEY = "legacy-jsonl-v1:quarantine";
3174
+ var SCHEMA_VERSION = 2;
3175
+ var SQLITE_FIXED_OVERHEAD_BYTES = 16 * 1024 * 1024;
3176
+ var MIN_SQLITE_PAGE_BUDGET_BYTES = 64 * 1024;
3177
+ var MAX_WAL_RESERVE_BYTES = 32 * 1024 * 1024;
3178
+ var WAL_AUTOCHECKPOINT_PAGES = 2e3;
3179
+ var WAL_SIZE_LIMIT_BYTES = 16 * 1024 * 1024;
3180
+ var TRIM_SLACK_RATIO = 0.02;
3181
+ var MAX_TRIM_SLACK_EVENTS = 2e3;
3182
+ var ChronicleStorageQuotaError = class extends Error {
3183
+ currentBytes;
3184
+ batchBytes;
3185
+ maxBytes;
3186
+ path;
3187
+ constructor(details) {
3188
+ super(
3189
+ `Chronicle SQLite quota exceeded at ${details.path}: ${details.currentBytes} live bytes + ${details.batchBytes} batch bytes exceeds ${details.maxBytes}; lower chronicle retentionDays/maxEvents to shed data (run chronicle compact to return the freed pages to the filesystem)`
3190
+ );
3191
+ this.name = "ChronicleStorageQuotaError";
3192
+ this.currentBytes = details.currentBytes;
3193
+ this.batchBytes = details.batchBytes;
3194
+ this.maxBytes = details.maxBytes;
3195
+ this.path = details.path;
3747
3196
  }
3748
- if (query.to) {
3749
- clauses.push("occurred_at <= ?");
3750
- params.push(query.to);
3197
+ };
3198
+ var Ctor;
3199
+ function loadDatabaseSync() {
3200
+ if (Ctor) return Ctor;
3201
+ if (Ctor === null) throw new Error("node:sqlite is unavailable in this runtime");
3202
+ try {
3203
+ Ctor = withSqliteExperimentalWarningSuppressed(
3204
+ () => createRequire(import.meta.url)("node:sqlite").DatabaseSync
3205
+ );
3206
+ return Ctor;
3207
+ } catch (error) {
3208
+ Ctor = null;
3209
+ throw new Error(
3210
+ "The Chronicle journal needs Node's built-in SQLite (node:sqlite, Node >= 22.5): " + (error instanceof Error ? error.message : String(error))
3211
+ );
3751
3212
  }
3752
- return { clause: clauses.length ? clauses.join(" AND ") : "1=1", params };
3753
3213
  }
3754
- var ChronicleSqliteQueryEngine = class {
3755
- constructor(db, options = {}) {
3756
- this.db = db;
3757
- this.batchSize = Math.max(1, options.batchSize ?? 1e3);
3758
- }
3214
+ var ChronicleSqliteJournal = class {
3759
3215
  db;
3760
- diagnostics = { sourceFiles: 1, invalidLines: 0 };
3761
- batchSize;
3762
- async query(query = {}) {
3763
- const order = query.order ?? "desc";
3764
- const limit = Math.max(1, Math.min(query.limit ?? 100, MAX_LIMIT));
3765
- const cursor = decodeCursor2(query.cursor);
3766
- const pushed = pushDown(query);
3767
- const direction = order === "asc" ? "ASC" : "DESC";
3768
- const comparison = order === "asc" ? ">" : "<";
3769
- const keyset = cursor ? ` AND (day, sequence) ${comparison} (?, ?)` : "";
3770
- const sql = `SELECT day, sequence, payload FROM events WHERE ${pushed.clause}${keyset} ORDER BY day ${direction}, sequence ${direction} LIMIT ? OFFSET ?`;
3771
- const summary = createSummaryAccumulator();
3772
- const page = [];
3773
- let total = 0;
3774
- let scannedEvents = 0;
3775
- let last;
3776
- let offset = 0;
3777
- for (; ; ) {
3778
- const params = [...pushed.params];
3779
- if (cursor) params.push(cursor.day, cursor.sequence);
3780
- params.push(this.batchSize, offset);
3781
- const rows = this.db.prepare(sql).all(...params);
3782
- if (rows.length === 0) break;
3783
- offset += rows.length;
3784
- for (const row of rows) {
3785
- scannedEvents++;
3786
- let event;
3787
- try {
3788
- event = JSON.parse(row.payload);
3789
- } catch {
3790
- this.diagnostics.invalidLines++;
3791
- continue;
3792
- }
3793
- if (!matches(event, query)) continue;
3794
- total++;
3795
- updateSummary(summary, event);
3796
- if (page.length < limit) {
3797
- page.push(event);
3798
- last = { day: row.day, sequence: row.sequence };
3799
- }
3800
- }
3801
- if (rows.length < this.batchSize) break;
3802
- }
3803
- page.sort((left, right) => compareEvents(left, right) * (order === "asc" ? 1 : -1));
3804
- const result = {
3805
- events: page,
3806
- total,
3807
- scannedEvents,
3808
- sourceFiles: this.diagnostics.sourceFiles,
3809
- invalidLines: this.diagnostics.invalidLines,
3810
- summary: finalizeSummary(summary)
3811
- };
3812
- if (total > page.length && last) {
3813
- return { ...result, nextCursor: encodeCursor2(last) };
3814
- }
3815
- return result;
3816
- }
3216
+ dbPath;
3217
+ now;
3218
+ monotonicNow;
3219
+ idFactory;
3220
+ retentionDays;
3221
+ maxEvents;
3222
+ maxBytes;
3223
+ retentionCheckIntervalMs;
3224
+ /** Overshoot allowed above `maxEvents` before eviction runs; see TRIM_SLACK_RATIO. */
3225
+ trimSlack;
3226
+ nextRetentionCheckAt = 0;
3227
+ retainedEventCount = 0;
3228
+ /** Resolved lazily by `pageSizeBytes()`; constant for an open database. */
3229
+ cachedPageSize;
3817
3230
  /**
3818
- * Value counts per facet field.
3231
+ * Bytes the quota is known to have had spare at the last real measurement,
3232
+ * and the bytes appended since. `assertWithinByteQuota` re-measures only once
3233
+ * the second could plausibly have consumed the first — see its comment.
3234
+ */
3235
+ quotaHeadroomBytes = 0;
3236
+ bytesSinceQuotaCheck = Number.POSITIVE_INFINITY;
3237
+ /**
3238
+ * Statements reused across appends.
3819
3239
  *
3820
- * Not a SQL `GROUP BY`: `facetValue()` reads fields that live inside the
3821
- * payload provider, model, tool call, tag so grouping in SQL would only
3822
- * work for the handful that happen to be columns and would need a second,
3823
- * divergent definition for the rest. The narrowing is still done by SQL; the
3824
- * counting uses the JSONL engine's own projection.
3240
+ * `db.prepare` re-parses the SQL every call, and the append path used to
3241
+ * prepare five statements per batch. They are held rather than re-prepared
3242
+ * because the schema cannot change under an open journal.
3825
3243
  */
3826
- async facets(fields, query = {}, limit = 100) {
3827
- const uniqueFields = [...new Set(fields)];
3828
- if (uniqueFields.length === 0) return {};
3829
- const counts = new Map(uniqueFields.map((field) => [field, /* @__PURE__ */ new Map()]));
3830
- for (const event of this.eachMatch(query)) {
3831
- for (const field of uniqueFields) {
3832
- const value = facetValue(event, field);
3833
- if (value === void 0) continue;
3834
- const fieldCounts = counts.get(field);
3835
- fieldCounts?.set(value, (fieldCounts.get(value) ?? 0) + 1);
3836
- }
3244
+ statements;
3245
+ /**
3246
+ * Cached chain head per day. Cleared wholesale on any write failure so the
3247
+ * next append rebuilds from the database rather than trusting a counter that
3248
+ * may have advanced past what actually committed.
3249
+ */
3250
+ anchors = /* @__PURE__ */ new Map();
3251
+ counters = {
3252
+ acceptedEvents: 0,
3253
+ persistedEvents: 0,
3254
+ rejectedEvents: 0,
3255
+ failedEvents: 0,
3256
+ batches: 0,
3257
+ maxObservedPending: 0,
3258
+ largestBatch: 0
3259
+ };
3260
+ lastBatchDurationMs;
3261
+ constructor(options) {
3262
+ this.dbPath = path12.join(path12.resolve(options.directory), CHRONICLE_SQLITE_FILE);
3263
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
3264
+ this.monotonicNow = options.monotonicNow ?? (() => process.hrtime.bigint());
3265
+ this.idFactory = options.idFactory ?? (() => randomUUID3());
3266
+ if (options.retentionDays !== void 0 && (!Number.isFinite(options.retentionDays) || options.retentionDays <= 0)) {
3267
+ throw new RangeError("retentionDays must be a positive finite number");
3837
3268
  }
3838
- const result = {};
3839
- for (const field of uniqueFields) {
3840
- result[field] = [...counts.get(field) ?? /* @__PURE__ */ new Map()].map(([value, count]) => ({ value, count })).sort((left, right) => right.count - left.count || left.value.localeCompare(right.value)).slice(0, Math.max(0, limit));
3269
+ if (options.retentionCheckIntervalMs !== void 0 && (!Number.isFinite(options.retentionCheckIntervalMs) || options.retentionCheckIntervalMs <= 0)) {
3270
+ throw new RangeError("retentionCheckIntervalMs must be a positive finite number");
3271
+ }
3272
+ if (options.maxEvents !== void 0 && (!Number.isInteger(options.maxEvents) || options.maxEvents < 1)) {
3273
+ throw new RangeError("maxEvents must be a positive integer");
3274
+ }
3275
+ if (options.maxBytes !== void 0 && (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 1)) {
3276
+ throw new RangeError("maxBytes must be a positive safe integer");
3277
+ }
3278
+ const minimumQuotaBytes = SQLITE_FIXED_OVERHEAD_BYTES + 2 * MIN_SQLITE_PAGE_BUDGET_BYTES;
3279
+ if (options.maxBytes !== void 0 && options.maxBytes < minimumQuotaBytes) {
3280
+ throw new RangeError(`maxBytes must be at least ${minimumQuotaBytes}`);
3281
+ }
3282
+ this.retentionDays = options.retentionDays;
3283
+ this.maxEvents = options.maxEvents;
3284
+ this.maxBytes = options.maxBytes;
3285
+ this.retentionCheckIntervalMs = options.retentionCheckIntervalMs ?? 60 * 60 * 1e3;
3286
+ this.trimSlack = this.maxEvents === void 0 ? 0 : Math.min(MAX_TRIM_SLACK_EVENTS, Math.floor(this.maxEvents * TRIM_SLACK_RATIO));
3287
+ const Database = loadDatabaseSync();
3288
+ this.db = new Database(this.dbPath);
3289
+ this.db.exec("PRAGMA journal_mode = WAL");
3290
+ this.db.exec("PRAGMA synchronous = FULL");
3291
+ this.db.exec(`PRAGMA wal_autocheckpoint = ${WAL_AUTOCHECKPOINT_PAGES}`);
3292
+ this.db.exec(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES}`);
3293
+ this.ensureSchema();
3294
+ this.configureByteQuota();
3295
+ if (this.maxEvents !== void 0) {
3296
+ const row = this.db.prepare("SELECT COUNT(*) AS count FROM events").get();
3297
+ this.retainedEventCount = row.count;
3298
+ this.enforceEventLimitAtStartup();
3841
3299
  }
3842
- return result;
3843
3300
  }
3844
- async facet(field, query = {}, limit = 100) {
3845
- return (await this.facets([field], query, limit))[field] ?? [];
3301
+ close() {
3302
+ this.db.close();
3303
+ }
3304
+ stats() {
3305
+ return {
3306
+ ...this.counters,
3307
+ pendingEvents: 0,
3308
+ // Retained at zero for wire compatibility: partitions do not exist here,
3309
+ // but `ChronicleJournalStats` is part of the IPC health payload.
3310
+ partitionRolls: 0,
3311
+ ...this.lastBatchDurationMs !== void 0 ? { lastBatchDurationMs: this.lastBatchDurationMs } : {}
3312
+ };
3846
3313
  }
3847
3314
  /**
3848
- * Expand explicit and typed correlation edges around a seed set.
3315
+ * Writes are synchronous and transactional, so there is nothing buffered to
3316
+ * flush. Kept to satisfy `ChronicleEventSink`.
3317
+ */
3318
+ async flush() {
3319
+ return Promise.resolve();
3320
+ }
3321
+ async append(input) {
3322
+ const [event] = await this.appendBatch([input]);
3323
+ return event;
3324
+ }
3325
+ /**
3326
+ * Append a batch as one transaction.
3849
3327
  *
3850
- * The traversal is the JSONL engine's, moved onto rows: `relationKeys()`
3851
- * defines the edges, `compareEvents` orders the nodes, and each hop is
3852
- * another ordered pass. Order is load-bearing rather than cosmetic both
3853
- * `maxNodes` truncations stop at whatever they reach first, so a differently
3854
- * ordered scan would return a different subgraph rather than the same one
3855
- * shuffled. `ORDER BY day, sequence` reproduces `comparePartitionPaths`
3856
- * (family, then rotation index) followed by line order within a partition.
3328
+ * The chain is computed in memory from a single anchor, so a partially
3329
+ * applied batch would leave a hole in `sequence` that verification can never
3330
+ * reconcile. Rollback plus anchor invalidation is what prevents that.
3857
3331
  */
3858
- async graph(seed = {}, hops = 2, maxNodes = 1e3) {
3859
- const nodeLimit = Math.max(0, Math.floor(maxNodes));
3860
- const selected = /* @__PURE__ */ new Map();
3861
- let seedCount = 0;
3862
- for (const event of this.eachMatch(seed)) {
3863
- seedCount++;
3864
- if (selected.size < nodeLimit) selected.set(event.eventId, event);
3865
- }
3866
- let frontier = [...selected.values()];
3867
- const depthLimit = Math.max(0, Math.min(hops, 10));
3868
- for (let depth = 0; depth < depthLimit && frontier.length > 0 && selected.size < nodeLimit; depth++) {
3869
- const frontierKeys = new Set(
3870
- frontier.flatMap((event) => relationKeys(event).map((relation) => relation.key))
3871
- );
3872
- const next = [];
3873
- for (const event of this.eachMatch({})) {
3874
- if (selected.has(event.eventId)) continue;
3875
- if (!relationKeys(event).some((relation) => frontierKeys.has(relation.key))) continue;
3876
- selected.set(event.eventId, event);
3877
- next.push(event);
3878
- if (selected.size >= nodeLimit) break;
3879
- }
3880
- frontier = next;
3332
+ async appendBatch(inputs) {
3333
+ if (inputs.length === 0) return [];
3334
+ const started = performance.now();
3335
+ this.counters.acceptedEvents += inputs.length;
3336
+ this.counters.batches += 1;
3337
+ this.counters.largestBatch = Math.max(this.counters.largestBatch, inputs.length);
3338
+ const instant = this.now().toISOString();
3339
+ const day = instant.slice(0, 10);
3340
+ const events = [];
3341
+ let previous = this.readAnchor(day);
3342
+ for (const input of inputs) {
3343
+ const unhashed = {
3344
+ ...input,
3345
+ occurredAt: input.occurredAt ?? instant,
3346
+ monotonicNs: input.monotonicNs ?? this.monotonicNow().toString(),
3347
+ schemaVersion: CHRONICLE_SCHEMA_VERSION,
3348
+ eventId: this.idFactory(),
3349
+ observedAt: instant,
3350
+ persistedAt: instant,
3351
+ sequence: previous.sequence + 1,
3352
+ previousHash: previous.hash
3353
+ };
3354
+ const event = { ...unhashed, hash: hashValue(unhashed) };
3355
+ events.push(event);
3356
+ previous = { sequence: event.sequence, hash: event.hash };
3881
3357
  }
3882
- const nodes = [...selected.values()].sort(compareEvents);
3883
- const byKey = /* @__PURE__ */ new Map();
3884
- for (const node of nodes) {
3885
- for (const relation of relationKeys(node)) {
3886
- const related = byKey.get(relation.key) ?? [];
3887
- related.push(node);
3888
- byKey.set(relation.key, related);
3358
+ const payloads = events.map((event) => JSON.stringify(event));
3359
+ let batchBytes = 0;
3360
+ for (const payload of payloads) batchBytes += Buffer.byteLength(payload, "utf8");
3361
+ let retainedCountAfterCommit = this.retainedEventCount;
3362
+ try {
3363
+ this.db.exec("BEGIN IMMEDIATE");
3364
+ this.assertWithinByteQuota(batchBytes);
3365
+ const { insert } = this.preparedStatements();
3366
+ for (const [index, event] of events.entries()) {
3367
+ const row = projectEvent(event);
3368
+ insert.run(
3369
+ day,
3370
+ event.sequence,
3371
+ event.eventId,
3372
+ event.hash,
3373
+ event.previousHash,
3374
+ row.occurredAt,
3375
+ event.eventType,
3376
+ row.outcome,
3377
+ row.projectId,
3378
+ row.sessionId,
3379
+ row.agentId,
3380
+ row.taskId,
3381
+ row.traceId,
3382
+ row.logicalRequestId,
3383
+ row.resourceKind,
3384
+ row.resourceId,
3385
+ row.resourcePath,
3386
+ row.durationNs,
3387
+ payloads[index]
3388
+ );
3889
3389
  }
3890
- }
3891
- const edges = [];
3892
- const seen = /* @__PURE__ */ new Set();
3893
- for (const node of nodes) {
3894
- for (const relation of relationKeys(node)) {
3895
- for (const candidate of byKey.get(relation.key) ?? []) {
3896
- if (candidate.eventId === node.eventId) continue;
3897
- const [from, to] = compareEvents(node, candidate) <= 0 ? [node, candidate] : [candidate, node];
3898
- const id = `${from.eventId}:${to.eventId}:${relation.kind}`;
3899
- if (seen.has(id)) continue;
3900
- seen.add(id);
3901
- edges.push({
3902
- from: from.eventId,
3903
- to: to.eventId,
3904
- kind: relation.kind,
3905
- confidence: relation.confidence
3906
- });
3907
- }
3390
+ retainedCountAfterCommit = this.enforceEventLimitWithinTransaction(
3391
+ this.retainedEventCount + events.length
3392
+ );
3393
+ this.assertActualAllocationWithinQuota();
3394
+ this.db.exec("COMMIT");
3395
+ } catch (error) {
3396
+ try {
3397
+ this.db.exec("ROLLBACK");
3398
+ } catch {
3908
3399
  }
3400
+ this.anchors.clear();
3401
+ this.invalidateQuotaEstimate();
3402
+ this.counters.failedEvents += inputs.length;
3403
+ this.lastBatchDurationMs = performance.now() - started;
3404
+ throw this.normalizeQuotaError(error);
3909
3405
  }
3910
- return {
3911
- nodes,
3912
- edges,
3913
- truncated: seedCount > nodeLimit || selected.size >= nodeLimit
3914
- };
3406
+ this.anchors.set(day, previous);
3407
+ this.counters.persistedEvents += events.length;
3408
+ this.retainedEventCount = retainedCountAfterCommit;
3409
+ this.bytesSinceQuotaCheck += batchBytes;
3410
+ this.lastBatchDurationMs = performance.now() - started;
3411
+ await this.enforceRetentionIfDue();
3412
+ return events;
3915
3413
  }
3916
- /** Every event satisfying `query`, pulled in batches so a wide scan stays bounded. */
3917
- *eachMatch(query) {
3918
- const pushed = pushDown(query);
3919
- const sql = `SELECT payload FROM events WHERE ${pushed.clause} ORDER BY day, sequence LIMIT ? OFFSET ?`;
3920
- let offset = 0;
3921
- for (; ; ) {
3922
- const rows = this.db.prepare(sql).all(...pushed.params, this.batchSize, offset);
3923
- if (rows.length === 0) return;
3924
- offset += rows.length;
3414
+ async readAll() {
3415
+ const rows = this.db.prepare("SELECT payload FROM events ORDER BY day, sequence").all();
3416
+ return rows.map((row) => JSON.parse(row.payload));
3417
+ }
3418
+ /**
3419
+ * Walk every chain and prove none has been edited.
3420
+ *
3421
+ * Chronicle chains are scoped to a day, not to the journal: the JSONL writer
3422
+ * anchors each `<day>.events.jsonl` family at `GENESIS_HASH` independently,
3423
+ * so `sequence` restarts at 1 every day. Verification mirrors that — each day
3424
+ * is validated on its own, and a break in one does not implicate the others.
3425
+ *
3426
+ * Three independent properties per day, because each catches a different
3427
+ * failure: a dense `sequence` catches deletions from the middle, the
3428
+ * `previousHash` link catches reordering, and re-hashing the payload catches
3429
+ * an in-place edit that left the links intact.
3430
+ */
3431
+ async verify() {
3432
+ let entries = 0;
3433
+ let lastSequence = 0;
3434
+ let lastHash = GENESIS_HASH;
3435
+ for (const day of this.days()) {
3436
+ const checkpoint = this.readCheckpoint(day);
3437
+ let expectedSequence = (checkpoint?.sequence ?? 0) + 1;
3438
+ let previousHash = checkpoint?.hash ?? GENESIS_HASH;
3439
+ const rows = this.db.prepare(
3440
+ "SELECT sequence, hash, previous_hash, payload FROM events WHERE day = ? ORDER BY sequence"
3441
+ ).all(day);
3925
3442
  for (const row of rows) {
3443
+ if (row.sequence !== expectedSequence) {
3444
+ return {
3445
+ ok: false,
3446
+ entries,
3447
+ brokenAt: entries,
3448
+ reason: `sequence gap in ${day}: expected ${expectedSequence}, found ${row.sequence}`
3449
+ };
3450
+ }
3451
+ if (row.previous_hash !== previousHash) {
3452
+ return { ok: false, entries, brokenAt: entries, reason: "previous hash mismatch" };
3453
+ }
3926
3454
  let event;
3927
3455
  try {
3928
3456
  event = JSON.parse(row.payload);
3929
3457
  } catch {
3930
- this.diagnostics.invalidLines++;
3931
- continue;
3458
+ return { ok: false, entries, brokenAt: entries, reason: "invalid payload JSON" };
3932
3459
  }
3933
- if (matches(event, query)) yield event;
3460
+ if (chronicleEventHash(event) !== row.hash) {
3461
+ return { ok: false, entries, brokenAt: entries, reason: "entry hash mismatch" };
3462
+ }
3463
+ entries += 1;
3464
+ expectedSequence = row.sequence + 1;
3465
+ previousHash = row.hash;
3934
3466
  }
3935
- if (rows.length < this.batchSize) return;
3467
+ lastSequence = expectedSequence - 1;
3468
+ lastHash = previousHash;
3936
3469
  }
3470
+ return { ok: true, entries, lastSequence, lastHash };
3937
3471
  }
3938
- };
3939
-
3940
- // src/chronicle/sqlite-journal.ts
3941
- var CHRONICLE_SQLITE_FILE = "chronicle.sqlite";
3942
- var LEGACY_JSONL_MIGRATION_KEY = "legacy-jsonl-v1";
3943
- var LEGACY_JSONL_QUARANTINE_KEY = "legacy-jsonl-v1:quarantine";
3944
- var SCHEMA_VERSION2 = 1;
3945
- var SQLITE_FIXED_OVERHEAD_BYTES = 16 * 1024 * 1024;
3946
- var MIN_SQLITE_PAGE_BUDGET_BYTES = 64 * 1024;
3947
- var MAX_ROLLBACK_JOURNAL_RESERVE_BYTES = 64 * 1024 * 1024;
3948
- var ChronicleStorageQuotaError = class extends Error {
3949
- currentBytes;
3950
- batchBytes;
3951
- maxBytes;
3952
- path;
3953
- constructor(details) {
3954
- super(
3955
- `Chronicle SQLite quota exceeded at ${details.path}: ${details.currentBytes} live bytes + ${details.batchBytes} batch bytes exceeds ${details.maxBytes}; lower chronicle retentionDays/maxEvents to shed data (run chronicle compact to return the freed pages to the filesystem)`
3956
- );
3957
- this.name = "ChronicleStorageQuotaError";
3958
- this.currentBytes = details.currentBytes;
3959
- this.batchBytes = details.batchBytes;
3960
- this.maxBytes = details.maxBytes;
3961
- this.path = details.path;
3472
+ async enforceRetentionIfDue() {
3473
+ if (this.retentionDays === void 0) return;
3474
+ const now = this.now();
3475
+ if (now.getTime() < this.nextRetentionCheckAt) return;
3476
+ this.nextRetentionCheckAt = now.getTime() + this.retentionCheckIntervalMs;
3477
+ try {
3478
+ await this.purge({ retentionDays: this.retentionDays });
3479
+ } catch {
3480
+ }
3962
3481
  }
3963
- };
3964
- var Ctor2;
3965
- function loadDatabaseSync2() {
3966
- if (Ctor2) return Ctor2;
3967
- if (Ctor2 === null) throw new Error("node:sqlite is unavailable in this runtime");
3968
- try {
3969
- Ctor2 = withSqliteExperimentalWarningSuppressed(
3970
- () => createRequire2(import.meta.url)("node:sqlite").DatabaseSync
3482
+ configureByteQuota() {
3483
+ if (this.maxBytes === void 0) {
3484
+ this.db.exec("PRAGMA max_page_count = 2147483646");
3485
+ return;
3486
+ }
3487
+ const halfSplit = Math.floor((this.maxBytes - SQLITE_FIXED_OVERHEAD_BYTES) / 2);
3488
+ const sidecarReserve = Math.min(MAX_WAL_RESERVE_BYTES, halfSplit);
3489
+ const mainBudget = this.maxBytes - SQLITE_FIXED_OVERHEAD_BYTES - sidecarReserve;
3490
+ if (mainBudget < MIN_SQLITE_PAGE_BUDGET_BYTES) {
3491
+ throw new Error(
3492
+ `maxBytes must be at least ${SQLITE_FIXED_OVERHEAD_BYTES + 2 * MIN_SQLITE_PAGE_BUDGET_BYTES}`
3493
+ );
3494
+ }
3495
+ const maxPages = Math.max(1, Math.floor(mainBudget / this.pageSizeBytes()));
3496
+ this.db.exec(`PRAGMA max_page_count = ${maxPages}`);
3497
+ }
3498
+ /**
3499
+ * Bytes the journal actually occupies: live pages in the main database plus
3500
+ * whatever the rollback/WAL sidecars currently hold.
3501
+ *
3502
+ * The quota MUST be measured this way rather than by `statSync` on the main
3503
+ * database file. SQLite never returns freed pages to the filesystem — it
3504
+ * parks them on the freelist and reuses them — so a database that once grew
3505
+ * large keeps that size forever, even after retention and `maxEvents` have
3506
+ * evicted almost everything. Measuring the file instead wedged the journal
3507
+ * permanently: a 3.9 GB file whose live data was 243 MB failed a 512 MB
3508
+ * quota on EVERY append, and the only escape (`chronicle compact`) refuses
3509
+ * to run while the daemon is up — and the daemon respawns on demand. Live
3510
+ * pages shrink when retention deletes rows, so the quota can recover on its
3511
+ * own; allocated size cannot.
3512
+ *
3513
+ * The sidecars keep their on-disk size: they are transient and genuinely
3514
+ * bounded by `configureByteQuota`'s half-of-budget split.
3515
+ */
3516
+ aggregateLiveBytes() {
3517
+ let total = 0;
3518
+ for (const file of [`${this.dbPath}-journal`, `${this.dbPath}-wal`, `${this.dbPath}-shm`]) {
3519
+ try {
3520
+ total += fs7.statSync(file).size;
3521
+ } catch (error) {
3522
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
3523
+ }
3524
+ }
3525
+ return total + this.mainDatabaseLiveBytes();
3526
+ }
3527
+ /** Live (non-freelist) pages of the main database, in bytes. */
3528
+ mainDatabaseLiveBytes() {
3529
+ const pageCount = Number(
3530
+ this.db.prepare("PRAGMA page_count").get().page_count
3971
3531
  );
3972
- return Ctor2;
3973
- } catch (error) {
3974
- Ctor2 = null;
3975
- throw new Error(
3976
- "The Chronicle journal needs Node's built-in SQLite (node:sqlite, Node >= 22.5): " + (error instanceof Error ? error.message : String(error))
3532
+ const freelist = Number(
3533
+ this.db.prepare("PRAGMA freelist_count").get().freelist_count
3977
3534
  );
3535
+ return Math.max(0, pageCount - freelist) * this.pageSizeBytes();
3536
+ }
3537
+ /** Page size never changes for an open database; resolve it once. */
3538
+ pageSizeBytes() {
3539
+ if (this.cachedPageSize === void 0) {
3540
+ this.cachedPageSize = Number(
3541
+ this.db.prepare("PRAGMA page_size").get().page_size
3542
+ );
3543
+ }
3544
+ return this.cachedPageSize;
3545
+ }
3546
+ normalizeQuotaError(error) {
3547
+ if (this.maxBytes === void 0 || !(error instanceof Error)) return error;
3548
+ const sqliteError = error;
3549
+ const quotaExhausted = sqliteError.code === "SQLITE_FULL" || sqliteError.code === 13 || sqliteError.errcode === 13 || /database or disk is full/i.test(sqliteError.message);
3550
+ if (!quotaExhausted) return error;
3551
+ return new ChronicleStorageQuotaError({
3552
+ currentBytes: this.aggregateLiveBytes(),
3553
+ batchBytes: 0,
3554
+ maxBytes: this.maxBytes,
3555
+ path: this.dbPath
3556
+ });
3557
+ }
3558
+ /** Drop the cached headroom so the next quota check measures for real. */
3559
+ invalidateQuotaEstimate() {
3560
+ this.quotaHeadroomBytes = 0;
3561
+ this.bytesSinceQuotaCheck = Number.POSITIVE_INFINITY;
3562
+ }
3563
+ assertActualAllocationWithinQuota() {
3564
+ if (this.maxBytes === void 0) return;
3565
+ if (!this.shouldMeasureQuota()) return;
3566
+ const currentBytes = this.aggregateLiveBytes();
3567
+ this.recordQuotaHeadroom(currentBytes);
3568
+ if (currentBytes <= this.maxBytes) return;
3569
+ throw new ChronicleStorageQuotaError({
3570
+ currentBytes,
3571
+ batchBytes: 0,
3572
+ maxBytes: this.maxBytes,
3573
+ path: this.dbPath
3574
+ });
3978
3575
  }
3979
- }
3980
- var ChronicleSqliteJournal = class {
3981
- db;
3982
- dbPath;
3983
- now;
3984
- monotonicNow;
3985
- idFactory;
3986
- retentionDays;
3987
- maxEvents;
3988
- maxBytes;
3989
- retentionCheckIntervalMs;
3990
- nextRetentionCheckAt = 0;
3991
- retainedEventCount = 0;
3992
- /** Resolved lazily by `pageSizeBytes()`; constant for an open database. */
3993
- cachedPageSize;
3994
3576
  /**
3995
- * Cached chain head per day. Cleared wholesale on any write failure so the
3996
- * next append rebuilds from the database rather than trusting a counter that
3997
- * may have advanced past what actually committed.
3577
+ * Refuse a batch that would take the journal past its byte quota.
3578
+ *
3579
+ * This is the *courteous* bound, not the enforced one: `max_page_count` makes
3580
+ * SQLite itself fail the write with `SQLITE_FULL`, which `normalizeQuotaError`
3581
+ * turns into the same {@link ChronicleStorageQuotaError}. Its only job is to
3582
+ * produce that error before a doomed batch has been written, so it does not
3583
+ * have to run on every append — and it should not, because measuring costs two
3584
+ * PRAGMA round trips and three `statSync` calls, twice per transaction.
3585
+ *
3586
+ * So it measures adaptively instead of on a fixed cadence: after a real
3587
+ * measurement it knows the spare bytes, and it may skip until the bytes
3588
+ * appended since then could plausibly have consumed half of them. Far from the
3589
+ * ceiling that is thousands of appends; close to it, every one. The bound
3590
+ * tightens exactly where being wrong would matter.
3998
3591
  */
3999
- anchors = /* @__PURE__ */ new Map();
4000
- counters = {
4001
- acceptedEvents: 0,
4002
- persistedEvents: 0,
4003
- rejectedEvents: 0,
4004
- failedEvents: 0,
4005
- batches: 0,
4006
- maxObservedPending: 0,
4007
- largestBatch: 0
4008
- };
4009
- lastBatchDurationMs;
4010
- constructor(options) {
4011
- this.dbPath = path14.join(path14.resolve(options.directory), CHRONICLE_SQLITE_FILE);
4012
- this.now = options.now ?? (() => /* @__PURE__ */ new Date());
4013
- this.monotonicNow = options.monotonicNow ?? (() => process.hrtime.bigint());
4014
- this.idFactory = options.idFactory ?? (() => randomUUID3());
4015
- if (options.retentionDays !== void 0 && (!Number.isFinite(options.retentionDays) || options.retentionDays <= 0)) {
4016
- throw new RangeError("retentionDays must be a positive finite number");
4017
- }
4018
- if (options.retentionCheckIntervalMs !== void 0 && (!Number.isFinite(options.retentionCheckIntervalMs) || options.retentionCheckIntervalMs <= 0)) {
4019
- throw new RangeError("retentionCheckIntervalMs must be a positive finite number");
4020
- }
4021
- if (options.maxEvents !== void 0 && (!Number.isInteger(options.maxEvents) || options.maxEvents < 1)) {
4022
- throw new RangeError("maxEvents must be a positive integer");
4023
- }
4024
- if (options.maxBytes !== void 0 && (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 1)) {
4025
- throw new RangeError("maxBytes must be a positive safe integer");
4026
- }
4027
- const minimumQuotaBytes = SQLITE_FIXED_OVERHEAD_BYTES + 2 * MIN_SQLITE_PAGE_BUDGET_BYTES;
4028
- if (options.maxBytes !== void 0 && options.maxBytes < minimumQuotaBytes) {
4029
- throw new RangeError(`maxBytes must be at least ${minimumQuotaBytes}`);
4030
- }
4031
- this.retentionDays = options.retentionDays;
4032
- this.maxEvents = options.maxEvents;
4033
- this.maxBytes = options.maxBytes;
4034
- this.retentionCheckIntervalMs = options.retentionCheckIntervalMs ?? 60 * 60 * 1e3;
4035
- const Database = loadDatabaseSync2();
4036
- this.db = new Database(this.dbPath);
4037
- this.db.exec(`PRAGMA journal_mode = ${this.maxBytes === void 0 ? "WAL" : "DELETE"}`);
4038
- this.ensureSchema();
4039
- this.configureByteQuota();
4040
- if (this.maxEvents !== void 0) {
4041
- const row = this.db.prepare("SELECT COUNT(*) AS count FROM events").get();
4042
- this.retainedEventCount = row.count;
4043
- this.enforceEventLimitAtStartup();
4044
- }
3592
+ assertWithinByteQuota(batchBytes) {
3593
+ if (this.maxBytes === void 0) return;
3594
+ if (!this.shouldMeasureQuota(batchBytes)) return;
3595
+ const currentBytes = this.aggregateLiveBytes();
3596
+ this.recordQuotaHeadroom(currentBytes);
3597
+ if (currentBytes + batchBytes <= this.maxBytes) return;
3598
+ throw new ChronicleStorageQuotaError({
3599
+ currentBytes,
3600
+ batchBytes,
3601
+ maxBytes: this.maxBytes,
3602
+ path: this.dbPath
3603
+ });
4045
3604
  }
4046
- close() {
4047
- this.db.close();
3605
+ /**
3606
+ * Is the cached headroom still large enough to vouch for this batch?
3607
+ *
3608
+ * Half the headroom is the budget deliberately: rows carry index and page
3609
+ * overhead beyond their payload bytes, so `bytesSinceQuotaCheck` understates
3610
+ * real growth, and the factor absorbs that without needing to model it.
3611
+ */
3612
+ shouldMeasureQuota(batchBytes = 0) {
3613
+ return this.bytesSinceQuotaCheck + batchBytes >= this.quotaHeadroomBytes / 2;
4048
3614
  }
4049
- stats() {
4050
- return {
4051
- ...this.counters,
4052
- pendingEvents: 0,
4053
- // Retained at zero for wire compatibility: partitions do not exist here,
4054
- // but `ChronicleJournalStats` is part of the IPC health payload.
4055
- partitionRolls: 0,
4056
- ...this.lastBatchDurationMs !== void 0 ? { lastBatchDurationMs: this.lastBatchDurationMs } : {}
4057
- };
3615
+ recordQuotaHeadroom(currentBytes) {
3616
+ this.quotaHeadroomBytes = Math.max(0, (this.maxBytes ?? 0) - currentBytes);
3617
+ this.bytesSinceQuotaCheck = 0;
4058
3618
  }
4059
3619
  /**
4060
- * Writes are synchronous and transactional, so there is nothing buffered to
4061
- * flush. Kept to satisfy `ChronicleEventSink`.
3620
+ * Evict the oldest events once the row ceiling has been overshot by `trimSlack`.
3621
+ *
3622
+ * @param count Rows now in the table. The caller tracks this rather than the
3623
+ * method querying it: `SELECT COUNT(*)` walks the whole table, and at the
3624
+ * ceiling — where every long-lived journal lives — that was a full scan of
3625
+ * `maxEvents` rows on every append, to learn a number the append path already
3626
+ * knew. The count is re-derived from the database whenever it could have
3627
+ * drifted (open, import, purge), never accumulated blindly across those.
3628
+ * @param slack Overshoot tolerated before evicting; callers that run once,
3629
+ * rather than per append, pass 0 to land on the exact ceiling.
4062
3630
  */
4063
- async flush() {
4064
- return Promise.resolve();
3631
+ enforceEventLimitWithinTransaction(count, slack = this.trimSlack) {
3632
+ if (this.maxEvents === void 0 || count <= this.maxEvents + slack) return count;
3633
+ const excess = count - this.maxEvents;
3634
+ const statements = this.preparedStatements();
3635
+ const boundary = statements.trimBoundary.get(excess - 1);
3636
+ if (!boundary) return count;
3637
+ statements.writeCheckpoint.run(boundary.day, boundary.sequence, boundary.hash);
3638
+ statements.deletePrefix.run(boundary.day, boundary.day, boundary.sequence);
3639
+ statements.deleteCheckpoints.run(boundary.day);
3640
+ for (const day of this.anchors.keys()) {
3641
+ if (day <= boundary.day) this.anchors.delete(day);
3642
+ }
3643
+ return this.maxEvents;
4065
3644
  }
4066
- async append(input) {
4067
- const [event] = await this.appendBatch([input]);
4068
- return event;
3645
+ enforceEventLimitAtStartup() {
3646
+ if (this.maxEvents === void 0 || this.retainedEventCount <= this.maxEvents) return;
3647
+ try {
3648
+ this.db.exec("BEGIN IMMEDIATE");
3649
+ const retainedCount = this.enforceEventLimitWithinTransaction(this.retainedEventCount, 0);
3650
+ this.db.exec("COMMIT");
3651
+ this.retainedEventCount = retainedCount;
3652
+ } catch (error) {
3653
+ try {
3654
+ this.db.exec("ROLLBACK");
3655
+ } catch {
3656
+ }
3657
+ throw error;
3658
+ }
4069
3659
  }
4070
3660
  /**
4071
- * Append a batch as one transaction.
3661
+ * Drop events older than the retention window.
4072
3662
  *
4073
- * The chain is computed in memory from a single anchor, so a partially
4074
- * applied batch would leave a hole in `sequence` that verification can never
4075
- * reconcile. Rollback plus anchor invalidation is what prevents that.
3663
+ * Retention is day-granular and chains are day-scoped, so a purge removes
3664
+ * whole chains rather than truncating one. That is why nothing needs to be
3665
+ * checkpointed here: there is no surviving suffix left dangling without an
3666
+ * anchor. Any checkpoint imported from a partially-purged legacy day family
3667
+ * is dropped alongside its events.
4076
3668
  */
4077
- async appendBatch(inputs) {
4078
- if (inputs.length === 0) return [];
4079
- const started = performance.now();
4080
- this.counters.acceptedEvents += inputs.length;
4081
- this.counters.batches += 1;
4082
- this.counters.largestBatch = Math.max(this.counters.largestBatch, inputs.length);
4083
- const instant = this.now().toISOString();
4084
- const day = instant.slice(0, 10);
4085
- const events = [];
4086
- let previous = this.readAnchor(day);
4087
- for (const input of inputs) {
4088
- const unhashed = {
4089
- ...input,
4090
- occurredAt: input.occurredAt ?? instant,
4091
- monotonicNs: input.monotonicNs ?? this.monotonicNow().toString(),
4092
- schemaVersion: CHRONICLE_SCHEMA_VERSION,
4093
- eventId: this.idFactory(),
4094
- observedAt: instant,
4095
- persistedAt: instant,
4096
- sequence: previous.sequence + 1,
4097
- previousHash: previous.hash
4098
- };
4099
- const event = { ...unhashed, hash: hashValue(unhashed) };
4100
- events.push(event);
4101
- previous = { sequence: event.sequence, hash: event.hash };
3669
+ async purge(options) {
3670
+ const empty = {
3671
+ deletedCount: 0,
3672
+ deletedBytes: 0,
3673
+ skippedCount: 0,
3674
+ errors: []
3675
+ };
3676
+ if (!Number.isFinite(options.retentionDays) || options.retentionDays <= 0) return empty;
3677
+ const cutoff = new Date(this.now().getTime() - options.retentionDays * 864e5).toISOString().slice(0, 10);
3678
+ const count = this.db.prepare("SELECT COUNT(*) AS n FROM events WHERE day < ?").get(cutoff).n;
3679
+ if (count === 0) return empty;
3680
+ if (options.dryRun) {
3681
+ const days = this.db.prepare("SELECT DISTINCT day FROM events WHERE day < ? ORDER BY day").all(cutoff);
3682
+ return { ...empty, deletedCount: count, candidates: days.map((row) => row.day) };
4102
3683
  }
4103
- let retainedCountAfterCommit = this.retainedEventCount;
4104
3684
  try {
4105
3685
  this.db.exec("BEGIN IMMEDIATE");
4106
- this.assertWithinByteQuota(events);
4107
- const insert = this.db.prepare(
4108
- `INSERT INTO events (
4109
- day, sequence, event_id, hash, previous_hash, occurred_at, event_type, outcome,
4110
- project_id, session_id, agent_id, task_id, trace_id, logical_request_id,
4111
- resource_kind, resource_id, resource_path, duration_ns, payload
4112
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
4113
- );
4114
- for (const event of events) {
4115
- const row = projectEvent(event);
4116
- insert.run(
4117
- day,
4118
- event.sequence,
4119
- event.eventId,
4120
- event.hash,
4121
- event.previousHash,
4122
- row.occurredAt,
4123
- event.eventType,
4124
- row.outcome,
4125
- row.projectId,
4126
- row.sessionId,
4127
- row.agentId,
4128
- row.taskId,
4129
- row.traceId,
4130
- row.logicalRequestId,
4131
- row.resourceKind,
4132
- row.resourceId,
4133
- row.resourcePath,
4134
- row.durationNs,
4135
- JSON.stringify(event)
4136
- );
3686
+ this.db.prepare("DELETE FROM events WHERE day < ?").run(cutoff);
3687
+ this.db.prepare("DELETE FROM chain_checkpoint WHERE day < ?").run(cutoff);
3688
+ this.db.exec("COMMIT");
3689
+ } catch (error) {
3690
+ try {
3691
+ this.db.exec("ROLLBACK");
3692
+ } catch {
4137
3693
  }
4138
- retainedCountAfterCommit = this.enforceEventLimitWithinTransaction();
3694
+ return {
3695
+ ...empty,
3696
+ errors: [
3697
+ {
3698
+ file: this.dbPath,
3699
+ reason: error instanceof Error ? error.message : String(error)
3700
+ }
3701
+ ]
3702
+ };
3703
+ }
3704
+ this.anchors.clear();
3705
+ this.retainedEventCount = this.countRows();
3706
+ this.invalidateQuotaEstimate();
3707
+ return { ...empty, deletedCount: count };
3708
+ }
3709
+ /**
3710
+ * Run one day family's legacy import inside its own transaction.
3711
+ *
3712
+ * Deliberately separate from `appendBatch`: the append path *computes*
3713
+ * `sequence`, `previousHash` and `hash`, while an import must carry them over
3714
+ * untouched. Fusing the two would put a code path one refactor away from
3715
+ * re-hashing historical events, which is the one change that silently
3716
+ * destroys their tamper evidence.
3717
+ *
3718
+ * The transaction is scoped to a single family because chains are: `sequence`
3719
+ * restarts at 1 each day, so one day's break says nothing about the next
3720
+ * day's integrity. A whole-journal transaction made every future day hostage
3721
+ * to the worst day on disk — one corrupt family and the daemon could never
3722
+ * open its store again. The family is still all-or-nothing: a break rolls
3723
+ * back that day entirely, so no partial chain is ever visible.
3724
+ */
3725
+ async runFamilyImport(load) {
3726
+ const insert = this.db.prepare(
3727
+ `INSERT INTO events (
3728
+ day, sequence, event_id, hash, previous_hash, occurred_at, event_type, outcome,
3729
+ project_id, session_id, agent_id, task_id, trace_id, logical_request_id,
3730
+ resource_kind, resource_id, resource_path, duration_ns, payload
3731
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
3732
+ );
3733
+ const checkpoint = this.db.prepare(
3734
+ `INSERT INTO chain_checkpoint (day, sequence, hash) VALUES (?, ?, ?)
3735
+ ON CONFLICT(day) DO UPDATE SET sequence = excluded.sequence, hash = excluded.hash`
3736
+ );
3737
+ this.db.exec("BEGIN IMMEDIATE");
3738
+ let retainedCountAfterCommit = this.retainedEventCount;
3739
+ try {
3740
+ this.invalidateQuotaEstimate();
3741
+ this.assertWithinByteQuota(0);
3742
+ await load({
3743
+ insert: (day, event) => {
3744
+ const row = projectEvent(event);
3745
+ insert.run(
3746
+ day,
3747
+ event.sequence,
3748
+ event.eventId,
3749
+ event.hash,
3750
+ event.previousHash,
3751
+ row.occurredAt,
3752
+ event.eventType,
3753
+ row.outcome,
3754
+ row.projectId,
3755
+ row.sessionId,
3756
+ row.agentId,
3757
+ row.taskId,
3758
+ row.traceId,
3759
+ row.logicalRequestId,
3760
+ row.resourceKind,
3761
+ row.resourceId,
3762
+ row.resourcePath,
3763
+ row.durationNs,
3764
+ JSON.stringify(event)
3765
+ );
3766
+ },
3767
+ checkpoint: (day, sequence, hash) => {
3768
+ checkpoint.run(day, sequence, hash);
3769
+ }
3770
+ });
3771
+ retainedCountAfterCommit = this.enforceEventLimitWithinTransaction(this.countRows(), 0);
3772
+ this.invalidateQuotaEstimate();
4139
3773
  this.assertActualAllocationWithinQuota();
4140
3774
  this.db.exec("COMMIT");
4141
3775
  } catch (error) {
@@ -4143,493 +3777,1041 @@ var ChronicleSqliteJournal = class {
4143
3777
  this.db.exec("ROLLBACK");
4144
3778
  } catch {
4145
3779
  }
4146
- this.anchors.clear();
4147
- this.counters.failedEvents += inputs.length;
4148
- this.lastBatchDurationMs = performance.now() - started;
3780
+ this.invalidateQuotaEstimate();
4149
3781
  throw this.normalizeQuotaError(error);
3782
+ } finally {
3783
+ this.anchors.clear();
4150
3784
  }
4151
- this.anchors.set(day, previous);
4152
- this.counters.persistedEvents += events.length;
4153
3785
  this.retainedEventCount = retainedCountAfterCommit;
4154
- this.lastBatchDurationMs = performance.now() - started;
4155
- await this.enforceRetentionIfDue();
4156
- return events;
4157
3786
  }
4158
- async readAll() {
4159
- const rows = this.db.prepare("SELECT payload FROM events ORDER BY day, sequence").all();
4160
- return rows.map((row) => JSON.parse(row.payload));
3787
+ /**
3788
+ * A read engine over this journal's own connection.
3789
+ *
3790
+ * Sharing the connection rather than opening a second one keeps the
3791
+ * single-writer guarantee intact and means a reader can never observe a
3792
+ * half-applied batch: SQLite serialises statements on one handle.
3793
+ */
3794
+ queryEngine(options) {
3795
+ return new ChronicleSqliteQueryEngine(this.db, options);
3796
+ }
3797
+ /** Has the legacy JSONL import already run? */
3798
+ hasImportedLegacyJournal() {
3799
+ return this.readMeta(LEGACY_JSONL_MIGRATION_KEY) !== void 0;
3800
+ }
3801
+ markLegacyJournalImported() {
3802
+ this.db.prepare(
3803
+ `INSERT INTO chronicle_meta (key, value) VALUES (?, 'done')
3804
+ ON CONFLICT(key) DO UPDATE SET value = 'done'`
3805
+ ).run(LEGACY_JSONL_MIGRATION_KEY);
4161
3806
  }
4162
3807
  /**
4163
- * Walk every chain and prove none has been edited.
3808
+ * Record the day families the import refused to move.
4164
3809
  *
4165
- * Chronicle chains are scoped to a day, not to the journal: the JSONL writer
4166
- * anchors each `<day>.events.jsonl` family at `GENESIS_HASH` independently,
4167
- * so `sequence` restarts at 1 every day. Verification mirrors that each day
4168
- * is validated on its own, and a break in one does not implicate the others.
3810
+ * Persisted rather than merely logged because the import runs once: after the
3811
+ * marker is set nothing re-reads the JSONL, so this row is the only surviving
3812
+ * evidence that a day was dropped. Health reports read it back to say
3813
+ * "degraded, and here is exactly what is missing" instead of quietly serving
3814
+ * a journal with a hole in it.
3815
+ */
3816
+ recordQuarantinedFamilies(families) {
3817
+ if (families.length === 0) return;
3818
+ this.db.prepare(
3819
+ `INSERT INTO chronicle_meta (key, value) VALUES (?, ?)
3820
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
3821
+ ).run(LEGACY_JSONL_QUARANTINE_KEY, JSON.stringify(families));
3822
+ }
3823
+ /**
3824
+ * Does this day already hold rows?
4169
3825
  *
4170
- * Three independent properties per day, because each catches a different
4171
- * failure: a dense `sequence` catches deletions from the middle, the
4172
- * `previousHash` link catches reordering, and re-hashing the payload catches
4173
- * an in-place edit that left the links intact.
3826
+ * Each family commits on its own, so an import interrupted between families
3827
+ * leaves a database that is complete for the days it reached. `(day,
3828
+ * sequence)` is the primary key, so re-inserting one of those days would
3829
+ * abort on a constraint violation rather than start over — this is what lets
3830
+ * the next run resume at the first day it never got to.
4174
3831
  */
4175
- async verify() {
4176
- let entries = 0;
4177
- let lastSequence = 0;
4178
- let lastHash = GENESIS_HASH;
4179
- for (const day of this.days()) {
4180
- const checkpoint = this.readCheckpoint(day);
4181
- let expectedSequence = (checkpoint?.sequence ?? 0) + 1;
4182
- let previousHash = checkpoint?.hash ?? GENESIS_HASH;
4183
- const rows = this.db.prepare(
4184
- "SELECT sequence, hash, previous_hash, payload FROM events WHERE day = ? ORDER BY sequence"
4185
- ).all(day);
4186
- for (const row of rows) {
4187
- if (row.sequence !== expectedSequence) {
4188
- return {
4189
- ok: false,
4190
- entries,
4191
- brokenAt: entries,
4192
- reason: `sequence gap in ${day}: expected ${expectedSequence}, found ${row.sequence}`
4193
- };
4194
- }
4195
- if (row.previous_hash !== previousHash) {
4196
- return { ok: false, entries, brokenAt: entries, reason: "previous hash mismatch" };
4197
- }
4198
- let event;
4199
- try {
4200
- event = JSON.parse(row.payload);
4201
- } catch {
4202
- return { ok: false, entries, brokenAt: entries, reason: "invalid payload JSON" };
4203
- }
4204
- if (chronicleEventHash(event) !== row.hash) {
4205
- return { ok: false, entries, brokenAt: entries, reason: "entry hash mismatch" };
4206
- }
4207
- entries += 1;
4208
- expectedSequence = row.sequence + 1;
4209
- previousHash = row.hash;
4210
- }
4211
- lastSequence = expectedSequence - 1;
4212
- lastHash = previousHash;
4213
- }
4214
- return { ok: true, entries, lastSequence, lastHash };
3832
+ hasImportedDay(day) {
3833
+ const row = this.db.prepare("SELECT 1 AS present FROM events WHERE day = ? LIMIT 1").get(day);
3834
+ return row !== void 0;
4215
3835
  }
4216
- async enforceRetentionIfDue() {
4217
- if (this.retentionDays === void 0) return;
4218
- const now = this.now();
4219
- if (now.getTime() < this.nextRetentionCheckAt) return;
4220
- this.nextRetentionCheckAt = now.getTime() + this.retentionCheckIntervalMs;
3836
+ /** Day families the legacy import refused to move, oldest first. */
3837
+ quarantinedFamilies() {
3838
+ const raw = this.readMeta(LEGACY_JSONL_QUARANTINE_KEY);
3839
+ if (!raw) return [];
4221
3840
  try {
4222
- await this.purge({ retentionDays: this.retentionDays });
3841
+ const parsed2 = JSON.parse(raw);
3842
+ return Array.isArray(parsed2) ? parsed2 : [];
4223
3843
  } catch {
3844
+ return [];
4224
3845
  }
4225
3846
  }
4226
- configureByteQuota() {
4227
- if (this.maxBytes === void 0) {
4228
- this.db.exec("PRAGMA max_page_count = 2147483646");
4229
- return;
4230
- }
4231
- const halfSplit = Math.floor((this.maxBytes - SQLITE_FIXED_OVERHEAD_BYTES) / 2);
4232
- const journalReserve = Math.min(MAX_ROLLBACK_JOURNAL_RESERVE_BYTES, halfSplit);
4233
- const mainBudget = this.maxBytes - SQLITE_FIXED_OVERHEAD_BYTES - journalReserve;
4234
- if (mainBudget < MIN_SQLITE_PAGE_BUDGET_BYTES) {
4235
- throw new Error(
4236
- `maxBytes must be at least ${SQLITE_FIXED_OVERHEAD_BYTES + 2 * MIN_SQLITE_PAGE_BUDGET_BYTES}`
4237
- );
4238
- }
4239
- const maxPages = Math.max(1, Math.floor(mainBudget / this.pageSizeBytes()));
4240
- this.db.exec(`PRAGMA max_page_count = ${maxPages}`);
3847
+ // ─── internals ────────────────────────────────────────────────────────────
3848
+ preparedStatements() {
3849
+ this.statements ??= {
3850
+ insert: this.db.prepare(
3851
+ `INSERT INTO events (
3852
+ day, sequence, event_id, hash, previous_hash, occurred_at, event_type, outcome,
3853
+ project_id, session_id, agent_id, task_id, trace_id, logical_request_id,
3854
+ resource_kind, resource_id, resource_path, duration_ns, payload
3855
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
3856
+ ),
3857
+ trimBoundary: this.db.prepare(
3858
+ "SELECT day, sequence, hash FROM events ORDER BY day, sequence LIMIT 1 OFFSET ?"
3859
+ ),
3860
+ writeCheckpoint: this.db.prepare(
3861
+ `INSERT INTO chain_checkpoint (day, sequence, hash) VALUES (?, ?, ?)
3862
+ ON CONFLICT(day) DO UPDATE SET sequence = excluded.sequence, hash = excluded.hash`
3863
+ ),
3864
+ deletePrefix: this.db.prepare(
3865
+ "DELETE FROM events WHERE day < ? OR (day = ? AND sequence <= ?)"
3866
+ ),
3867
+ deleteCheckpoints: this.db.prepare("DELETE FROM chain_checkpoint WHERE day < ?")
3868
+ };
3869
+ return this.statements;
3870
+ }
3871
+ /** Rows currently in the table, straight from the database. */
3872
+ countRows() {
3873
+ return this.db.prepare("SELECT COUNT(*) AS count FROM events").get().count;
3874
+ }
3875
+ readMeta(key) {
3876
+ const row = this.db.prepare("SELECT value FROM chronicle_meta WHERE key = ?").get(key);
3877
+ return row?.value;
3878
+ }
3879
+ readCheckpoint(day) {
3880
+ return this.db.prepare("SELECT sequence, hash FROM chain_checkpoint WHERE day = ?").get(day);
3881
+ }
3882
+ /** Days that currently hold at least one event, oldest first. */
3883
+ days() {
3884
+ const rows = this.db.prepare("SELECT DISTINCT day FROM events ORDER BY day").all();
3885
+ return rows.map((row) => row.day);
4241
3886
  }
4242
3887
  /**
4243
- * Bytes the journal actually occupies: live pages in the main database plus
4244
- * whatever the rollback/WAL sidecars currently hold.
4245
- *
4246
- * The quota MUST be measured this way rather than by `statSync` on the main
4247
- * database file. SQLite never returns freed pages to the filesystem — it
4248
- * parks them on the freelist and reuses them — so a database that once grew
4249
- * large keeps that size forever, even after retention and `maxEvents` have
4250
- * evicted almost everything. Measuring the file instead wedged the journal
4251
- * permanently: a 3.9 GB file whose live data was 243 MB failed a 512 MB
4252
- * quota on EVERY append, and the only escape (`chronicle compact`) refuses
4253
- * to run while the daemon is up — and the daemon respawns on demand. Live
4254
- * pages shrink when retention deletes rows, so the quota can recover on its
4255
- * own; allocated size cannot.
4256
- *
4257
- * The sidecars keep their on-disk size: they are transient and genuinely
4258
- * bounded by `configureByteQuota`'s half-of-budget split.
3888
+ * Resolve the chain head, in the same precedence the JSONL journal uses: the
3889
+ * newest event, else the retention checkpoint (every event before it was
3890
+ * purged), else genesis.
4259
3891
  */
4260
- aggregateLiveBytes() {
4261
- let total = 0;
4262
- for (const file of [`${this.dbPath}-journal`, `${this.dbPath}-wal`, `${this.dbPath}-shm`]) {
4263
- try {
4264
- total += fs9.statSync(file).size;
4265
- } catch (error) {
4266
- if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
4267
- }
3892
+ readAnchor(day) {
3893
+ const cached = this.anchors.get(day);
3894
+ if (cached) return cached;
3895
+ const last = this.db.prepare("SELECT sequence, hash FROM events WHERE day = ? ORDER BY sequence DESC LIMIT 1").get(day);
3896
+ const anchor = last ?? this.readCheckpoint(day) ?? { sequence: 0, hash: GENESIS_HASH };
3897
+ this.anchors.set(day, anchor);
3898
+ return anchor;
3899
+ }
3900
+ ensureSchema() {
3901
+ const version = this.db.prepare("PRAGMA user_version").get().user_version;
3902
+ this.db.exec(`
3903
+ CREATE TABLE IF NOT EXISTS events (
3904
+ day TEXT NOT NULL,
3905
+ sequence INTEGER NOT NULL,
3906
+ event_id TEXT NOT NULL UNIQUE,
3907
+ hash TEXT NOT NULL,
3908
+ previous_hash TEXT NOT NULL,
3909
+ occurred_at TEXT NOT NULL,
3910
+ event_type TEXT NOT NULL,
3911
+ outcome TEXT,
3912
+ project_id TEXT,
3913
+ session_id TEXT,
3914
+ agent_id TEXT,
3915
+ task_id TEXT,
3916
+ trace_id TEXT,
3917
+ logical_request_id TEXT,
3918
+ resource_kind TEXT,
3919
+ resource_id TEXT,
3920
+ resource_path TEXT,
3921
+ duration_ns TEXT,
3922
+ payload TEXT NOT NULL,
3923
+ PRIMARY KEY (day, sequence)
3924
+ );
3925
+ CREATE INDEX IF NOT EXISTS events_occurred_at ON events(occurred_at);
3926
+ CREATE INDEX IF NOT EXISTS events_type_outcome ON events(event_type, outcome);
3927
+ CREATE INDEX IF NOT EXISTS events_session ON events(session_id, day, sequence);
3928
+ CREATE INDEX IF NOT EXISTS events_trace ON events(trace_id);
3929
+ CREATE INDEX IF NOT EXISTS events_logical_request ON events(logical_request_id);
3930
+
3931
+ CREATE TABLE IF NOT EXISTS chain_checkpoint (
3932
+ day TEXT PRIMARY KEY,
3933
+ sequence INTEGER NOT NULL,
3934
+ hash TEXT NOT NULL
3935
+ );
3936
+
3937
+ CREATE TABLE IF NOT EXISTS chronicle_meta (
3938
+ key TEXT PRIMARY KEY,
3939
+ value TEXT NOT NULL
3940
+ );
3941
+ `);
3942
+ if (version < 2) {
3943
+ this.db.exec("DROP INDEX IF EXISTS events_resource_path");
3944
+ }
3945
+ if (version !== SCHEMA_VERSION) {
3946
+ this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
4268
3947
  }
4269
- return total + this.mainDatabaseLiveBytes();
4270
3948
  }
4271
- /** Live (non-freelist) pages of the main database, in bytes. */
4272
- mainDatabaseLiveBytes() {
4273
- const pageCount = Number(
4274
- this.db.prepare("PRAGMA page_count").get().page_count
3949
+ };
3950
+ function projectEvent(event) {
3951
+ const occurredAt = event.occurredAt ?? event.observedAt;
3952
+ return {
3953
+ occurredAt,
3954
+ outcome: event.outcome ?? null,
3955
+ projectId: event.scope.projectId ?? null,
3956
+ sessionId: event.scope.sessionId ?? null,
3957
+ agentId: event.scope.agentId ?? null,
3958
+ taskId: event.scope.taskId ?? null,
3959
+ traceId: event.correlation?.traceId ?? null,
3960
+ logicalRequestId: event.correlation?.logicalRequestId ?? null,
3961
+ resourceKind: event.resource?.kind ?? null,
3962
+ resourceId: event.resource?.id ?? null,
3963
+ resourcePath: event.resource?.path ?? null,
3964
+ durationNs: event.durationNs ?? null
3965
+ };
3966
+ }
3967
+
3968
+ // src/chronicle/metrics-store.ts
3969
+ var SCHEMA_VERSION2 = 3;
3970
+ var READ_CHUNK_BYTES = 1024 * 1024;
3971
+ var SQLITE_SOURCE_PREFIX = "sqlite:";
3972
+ var SQLITE_INGEST_BATCH = 2e3;
3973
+ var EMPTY_FAMILIES = {
3974
+ llm: 0,
3975
+ agent: 0,
3976
+ tool: 0,
3977
+ file: 0,
3978
+ memory: 0,
3979
+ task: 0,
3980
+ decision: 0,
3981
+ runtime: 0,
3982
+ finding: 0
3983
+ };
3984
+ var Ctor2;
3985
+ function loadDatabaseSync2() {
3986
+ if (Ctor2) return Ctor2;
3987
+ if (Ctor2 === null) throw new Error("node:sqlite is unavailable in this runtime");
3988
+ try {
3989
+ Ctor2 = withSqliteExperimentalWarningSuppressed(
3990
+ () => createRequire2(import.meta.url)("node:sqlite").DatabaseSync
4275
3991
  );
4276
- const freelist = Number(
4277
- this.db.prepare("PRAGMA freelist_count").get().freelist_count
3992
+ return Ctor2;
3993
+ } catch (error) {
3994
+ Ctor2 = null;
3995
+ throw new Error(
3996
+ "Chronicle metrics need Node's built-in SQLite (node:sqlite, Node >= 22.5): " + (error instanceof Error ? error.message : String(error))
4278
3997
  );
4279
- return Math.max(0, pageCount - freelist) * this.pageSizeBytes();
4280
3998
  }
4281
- /** Page size never changes for an open database; resolve it once. */
4282
- pageSizeBytes() {
4283
- if (this.cachedPageSize === void 0) {
4284
- this.cachedPageSize = Number(
4285
- this.db.prepare("PRAGMA page_size").get().page_size
4286
- );
4287
- }
4288
- return this.cachedPageSize;
3999
+ }
4000
+ var ChronicleMetricsStore = class _ChronicleMetricsStore {
4001
+ db;
4002
+ directory;
4003
+ dbPath;
4004
+ constructor(directory) {
4005
+ this.directory = path13.resolve(directory);
4006
+ this.dbPath = path13.join(this.directory, "metrics.db");
4007
+ const Database = loadDatabaseSync2();
4008
+ this.db = new Database(this.dbPath);
4009
+ this.db.exec("PRAGMA journal_mode = WAL");
4010
+ this.ensureSchema();
4289
4011
  }
4290
- normalizeQuotaError(error) {
4291
- if (this.maxBytes === void 0 || !(error instanceof Error)) return error;
4292
- const sqliteError = error;
4293
- const quotaExhausted = sqliteError.code === "SQLITE_FULL" || sqliteError.code === 13 || sqliteError.errcode === 13 || /database or disk is full/i.test(sqliteError.message);
4294
- if (!quotaExhausted) return error;
4295
- return new ChronicleStorageQuotaError({
4296
- currentBytes: this.aggregateLiveBytes(),
4297
- batchBytes: 0,
4298
- maxBytes: this.maxBytes,
4299
- path: this.dbPath
4300
- });
4012
+ static open(chronicleDirectory2) {
4013
+ return new _ChronicleMetricsStore(chronicleDirectory2);
4301
4014
  }
4302
- assertActualAllocationWithinQuota() {
4303
- if (this.maxBytes === void 0) return;
4304
- const currentBytes = this.aggregateLiveBytes();
4305
- if (currentBytes <= this.maxBytes) return;
4306
- throw new ChronicleStorageQuotaError({
4307
- currentBytes,
4308
- batchBytes: 0,
4309
- maxBytes: this.maxBytes,
4310
- path: this.dbPath
4311
- });
4015
+ close() {
4016
+ this.db.close();
4312
4017
  }
4313
- assertWithinByteQuota(events) {
4314
- if (this.maxBytes === void 0) return;
4315
- const currentBytes = this.aggregateLiveBytes();
4316
- const batchBytes = Buffer.byteLength(JSON.stringify(events), "utf8");
4317
- if (currentBytes + batchBytes <= this.maxBytes) return;
4318
- throw new ChronicleStorageQuotaError({
4319
- currentBytes,
4320
- batchBytes,
4321
- maxBytes: this.maxBytes,
4322
- path: this.dbPath
4018
+ /** Incrementally ingest journal bytes appended since the last refresh.
4019
+ * Safe across processes: guarded by a file lock on the database path. */
4020
+ async refresh() {
4021
+ const result = {
4022
+ ingestedEvents: 0,
4023
+ ingestedBytes: 0,
4024
+ sourceFiles: 0,
4025
+ invalidLines: 0
4026
+ };
4027
+ await withFileLock(this.dbPath, async () => {
4028
+ const files = await findChroniclePartitions(this.directory);
4029
+ const offsets = this.loadOffsets();
4030
+ for (const file of files) {
4031
+ const key = normalizeKey(path13.relative(this.directory, file));
4032
+ const consumed = offsets.get(key) ?? 0;
4033
+ const ingested = await this.ingestFile(file, key, consumed, result);
4034
+ if (ingested) result.sourceFiles++;
4035
+ }
4036
+ this.pruneOffsets(files);
4037
+ this.ingestSqliteJournal(offsets, result);
4323
4038
  });
4039
+ return result;
4324
4040
  }
4325
- enforceEventLimitWithinTransaction() {
4326
- const row = this.db.prepare("SELECT COUNT(*) AS count FROM events").get();
4327
- if (this.maxEvents === void 0 || row.count <= this.maxEvents) return row.count;
4328
- const excess = row.count - this.maxEvents;
4329
- const boundary = this.db.prepare("SELECT day, sequence, hash FROM events ORDER BY day, sequence LIMIT 1 OFFSET ?").get(excess - 1);
4330
- if (!boundary) return row.count;
4331
- this.db.prepare(
4332
- `INSERT INTO chain_checkpoint (day, sequence, hash) VALUES (?, ?, ?)
4333
- ON CONFLICT(day) DO UPDATE SET sequence = excluded.sequence, hash = excluded.hash`
4334
- ).run(boundary.day, boundary.sequence, boundary.hash);
4335
- this.db.prepare("DELETE FROM events WHERE day < ? OR (day = ? AND sequence <= ?)").run(boundary.day, boundary.day, boundary.sequence);
4336
- this.db.prepare("DELETE FROM chain_checkpoint WHERE day < ?").run(boundary.day);
4337
- this.anchors.clear();
4338
- return this.maxEvents;
4041
+ providerDaily(options = {}) {
4042
+ const clauses = [];
4043
+ const params = [];
4044
+ if (options.from) {
4045
+ clauses.push("day >= ?");
4046
+ params.push(options.from.slice(0, 10));
4047
+ }
4048
+ if (options.to) {
4049
+ clauses.push("day <= ?");
4050
+ params.push(options.to.slice(0, 10));
4051
+ }
4052
+ const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
4053
+ const rows = this.db.prepare(
4054
+ `SELECT day, provider_id, model_id, attempts, completed, failed, retries, fallbacks,
4055
+ input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
4056
+ duration_ms_total, duration_ms_max, duration_count
4057
+ FROM provider_daily${where} ORDER BY day DESC, provider_id, model_id`
4058
+ ).all(...params);
4059
+ return rows.map((row) => ({
4060
+ day: String(row.day),
4061
+ providerId: String(row.provider_id),
4062
+ modelId: String(row.model_id),
4063
+ attempts: Number(row.attempts),
4064
+ completed: Number(row.completed),
4065
+ failed: Number(row.failed),
4066
+ retries: Number(row.retries),
4067
+ fallbacks: Number(row.fallbacks),
4068
+ inputTokens: Number(row.input_tokens),
4069
+ outputTokens: Number(row.output_tokens),
4070
+ cacheReadTokens: Number(row.cache_read_tokens),
4071
+ cacheWriteTokens: Number(row.cache_write_tokens),
4072
+ avgDurationMs: Number(row.duration_count) > 0 ? Number(row.duration_ms_total) / Number(row.duration_count) : 0,
4073
+ maxDurationMs: Number(row.duration_ms_max)
4074
+ }));
4339
4075
  }
4340
- enforceEventLimitAtStartup() {
4341
- if (this.maxEvents === void 0 || this.retainedEventCount <= this.maxEvents) return;
4342
- try {
4343
- this.db.exec("BEGIN IMMEDIATE");
4344
- const retainedCount = this.enforceEventLimitWithinTransaction();
4345
- this.db.exec("COMMIT");
4346
- this.retainedEventCount = retainedCount;
4347
- } catch (error) {
4348
- try {
4349
- this.db.exec("ROLLBACK");
4350
- } catch {
4351
- }
4352
- throw error;
4076
+ taskOutcomes(options = {}) {
4077
+ const clauses = [];
4078
+ const params = [];
4079
+ if (options.runId) {
4080
+ clauses.push("t.run_id = ?");
4081
+ params.push(options.runId);
4082
+ }
4083
+ if (options.boardId) {
4084
+ clauses.push("t.board_id = ?");
4085
+ params.push(options.boardId);
4086
+ }
4087
+ if (options.sessionId) {
4088
+ clauses.push("t.session_id = ?");
4089
+ params.push(options.sessionId);
4090
+ }
4091
+ if (options.status) {
4092
+ clauses.push("t.status = ?");
4093
+ params.push(options.status);
4094
+ }
4095
+ const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
4096
+ params.push(clampLimit(options.limit, 100));
4097
+ const rows = this.db.prepare(
4098
+ `SELECT t.*, (SELECT COUNT(*) FROM file_lineage f WHERE f.task_id = t.task_id) AS files_touched
4099
+ FROM task_outcomes t${where}
4100
+ ORDER BY COALESCE(t.started_at, '') DESC LIMIT ?`
4101
+ ).all(...params);
4102
+ return rows.map((row) => ({
4103
+ taskId: String(row.task_id),
4104
+ runId: String(row.run_id),
4105
+ boardId: String(row.board_id),
4106
+ sessionId: String(row.session_id),
4107
+ agentId: String(row.agent_id),
4108
+ status: String(row.status),
4109
+ startedAt: row.started_at === null ? null : String(row.started_at),
4110
+ endedAt: row.ended_at === null ? null : String(row.ended_at),
4111
+ durationMs: row.duration_ms === null ? null : Number(row.duration_ms),
4112
+ retries: Number(row.retries),
4113
+ verificationFailures: Number(row.verification_failures),
4114
+ filesTouched: Number(row.files_touched)
4115
+ }));
4116
+ }
4117
+ fileLineage(options = {}) {
4118
+ const clauses = [];
4119
+ const params = [];
4120
+ if (options.path) {
4121
+ clauses.push("path_key = ?");
4122
+ params.push(normalizePathKey(options.path));
4123
+ }
4124
+ if (options.paths) {
4125
+ const pathKeys = [...new Set(options.paths.map(normalizePathKey))];
4126
+ if (pathKeys.length === 0) return [];
4127
+ clauses.push(`path_key IN (${pathKeys.map(() => "?").join(",")})`);
4128
+ params.push(...pathKeys);
4129
+ }
4130
+ if (options.taskId) {
4131
+ clauses.push("task_id = ?");
4132
+ params.push(options.taskId);
4133
+ }
4134
+ if (options.boardId) {
4135
+ clauses.push("board_id = ?");
4136
+ params.push(options.boardId);
4137
+ }
4138
+ if (options.sessionId) {
4139
+ clauses.push("session_id = ?");
4140
+ params.push(options.sessionId);
4141
+ }
4142
+ const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
4143
+ params.push(clampLimit(options.limit, 200));
4144
+ const projection = `path, operation, occurred_at, session_id, agent_id, task_id, board_id, run_id,
4145
+ tool_name, provider_id, model_id, source`;
4146
+ const sql = options.latestPerPath ? `SELECT ${projection} FROM (
4147
+ SELECT ${projection}, ROW_NUMBER() OVER (
4148
+ PARTITION BY path_key ORDER BY occurred_at DESC, event_id DESC
4149
+ ) AS path_rank
4150
+ FROM file_lineage${where}
4151
+ ) WHERE path_rank = 1 ORDER BY occurred_at DESC LIMIT ?` : `SELECT ${projection}
4152
+ FROM file_lineage${where} ORDER BY occurred_at DESC LIMIT ?`;
4153
+ const rows = this.db.prepare(sql).all(...params);
4154
+ return rows.map((row) => ({
4155
+ path: row.path,
4156
+ operation: row.operation,
4157
+ occurredAt: row.occurred_at,
4158
+ sessionId: row.session_id,
4159
+ agentId: row.agent_id,
4160
+ taskId: row.task_id,
4161
+ boardId: row.board_id,
4162
+ runId: row.run_id,
4163
+ toolName: row.tool_name,
4164
+ providerId: row.provider_id,
4165
+ modelId: row.model_id,
4166
+ source: row.source
4167
+ }));
4168
+ }
4169
+ summary() {
4170
+ const provider = this.db.prepare(
4171
+ "SELECT COALESCE(SUM(attempts),0) a, COALESCE(SUM(completed),0) c, COALESCE(SUM(failed),0) f FROM provider_daily"
4172
+ ).get();
4173
+ const tasks = {};
4174
+ for (const row of this.db.prepare("SELECT status, COUNT(*) n FROM task_outcomes GROUP BY status").all()) {
4175
+ tasks[row.status] = Number(row.n);
4353
4176
  }
4177
+ const files = this.db.prepare("SELECT COUNT(*) n, COUNT(DISTINCT path) p FROM file_lineage").get();
4178
+ const cost = this.db.prepare("SELECT COALESCE(SUM(cost),0) c FROM token_cost").get();
4179
+ const terminal = Number(provider.c) + Number(provider.f);
4180
+ return {
4181
+ providers: {
4182
+ attempts: Number(provider.a),
4183
+ completed: Number(provider.c),
4184
+ failed: Number(provider.f),
4185
+ successRate: terminal > 0 ? Number(provider.c) / terminal : 0
4186
+ },
4187
+ tasks,
4188
+ files: { mutations: Number(files.n), uniquePaths: Number(files.p) },
4189
+ estimatedCostUsd: Number(cost.c)
4190
+ };
4354
4191
  }
4355
4192
  /**
4356
- * Drop events older than the retention window.
4357
- *
4358
- * Retention is day-granular and chains are day-scoped, so a purge removes
4359
- * whole chains rather than truncating one. That is why nothing needs to be
4360
- * checkpointed here: there is no surviving suffix left dangling without an
4361
- * anchor. Any checkpoint imported from a partially-purged legacy day family
4362
- * is dropped alongside its events.
4193
+ * A `ChronicleSummary` for the default/unfiltered dashboard view — only
4194
+ * `from`/`to` (day-precision) narrow it. Any other ad hoc filter (text,
4195
+ * path, provider, model, session) can't be answered from these
4196
+ * fixed-dimension aggregates; callers must fall back to query.ts's
4197
+ * raw-scan summary for those.
4363
4198
  */
4364
- async purge(options) {
4365
- const empty = {
4366
- deletedCount: 0,
4367
- deletedBytes: 0,
4368
- skippedCount: 0,
4369
- errors: []
4199
+ defaultSummary(options = {}) {
4200
+ const fromDay = options.from?.slice(0, 10);
4201
+ const toDay = options.to?.slice(0, 10);
4202
+ const dayFilter = (column) => {
4203
+ const clauses = [];
4204
+ const params = [];
4205
+ if (fromDay) {
4206
+ clauses.push(`${column} >= ?`);
4207
+ params.push(fromDay);
4208
+ }
4209
+ if (toDay) {
4210
+ clauses.push(`${column} <= ?`);
4211
+ params.push(toDay);
4212
+ }
4213
+ return { where: clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "", params };
4370
4214
  };
4371
- if (!Number.isFinite(options.retentionDays) || options.retentionDays <= 0) return empty;
4372
- const cutoff = new Date(this.now().getTime() - options.retentionDays * 864e5).toISOString().slice(0, 10);
4373
- const count = this.db.prepare("SELECT COUNT(*) AS n FROM events WHERE day < ?").get(cutoff).n;
4374
- if (count === 0) return empty;
4375
- if (options.dryRun) {
4376
- const days = this.db.prepare("SELECT DISTINCT day FROM events WHERE day < ? ORDER BY day").all(cutoff);
4377
- return { ...empty, deletedCount: count, candidates: days.map((row) => row.day) };
4215
+ const providerRange = dayFilter("day");
4216
+ const provider = this.db.prepare(
4217
+ `SELECT COALESCE(SUM(attempts),0) attempts, COALESCE(SUM(completed),0) completed, COALESCE(SUM(failed),0) failed,
4218
+ COALESCE(SUM(retries),0) retries, COALESCE(SUM(fallbacks),0) fallbacks,
4219
+ COUNT(DISTINCT provider_id) providers, COUNT(DISTINCT model_id) models,
4220
+ COALESCE(SUM(input_tokens),0) inputTokens, COALESCE(SUM(output_tokens),0) outputTokens,
4221
+ COALESCE(SUM(cache_read_tokens),0) cacheReadTokens, COALESCE(SUM(cache_write_tokens),0) cacheWriteTokens,
4222
+ COALESCE(SUM(duration_ms_total),0) durationTotal, COALESCE(MAX(duration_ms_max),0) durationMax,
4223
+ COALESCE(SUM(duration_count),0) durationCount
4224
+ FROM provider_daily${providerRange.where}`
4225
+ ).get(...providerRange.params);
4226
+ const counterRange = dayFilter("day");
4227
+ const counters = this.db.prepare(
4228
+ `SELECT COALESCE(SUM(tool_calls),0) toolCalls, COALESCE(SUM(completed_tools),0) completedTools,
4229
+ COALESCE(SUM(failed_tools),0) failedTools, COALESCE(SUM(tool_duration_ms_total),0) toolDurationTotal,
4230
+ COALESCE(SUM(tool_duration_count),0) toolDurationCount, COALESCE(SUM(processes),0) processes,
4231
+ COALESCE(SUM(failed_processes),0) failedProcesses, COALESCE(SUM(file_events_all),0) fileEvents,
4232
+ COALESCE(SUM(decisions),0) decisions, COALESCE(SUM(escalations),0) escalations,
4233
+ COALESCE(SUM(agent_events),0) agentEvents, COALESCE(SUM(failures),0) failures,
4234
+ COALESCE(SUM(cancellations),0) cancellations
4235
+ FROM daily_counters${counterRange.where}`
4236
+ ).get(...counterRange.params);
4237
+ const familyRange = dayFilter("day");
4238
+ const familyRows = this.db.prepare(`SELECT family, count, failure_count FROM family_daily${familyRange.where}`).all(...familyRange.params);
4239
+ const families = { ...EMPTY_FAMILIES };
4240
+ const failuresByFamily = { ...EMPTY_FAMILIES };
4241
+ for (const row of familyRows) {
4242
+ const family = row.family;
4243
+ families[family] = Number(row.count);
4244
+ failuresByFamily[family] = Number(row.failure_count);
4378
4245
  }
4379
- try {
4380
- this.db.exec("BEGIN IMMEDIATE");
4381
- this.db.prepare("DELETE FROM events WHERE day < ?").run(cutoff);
4382
- this.db.prepare("DELETE FROM chain_checkpoint WHERE day < ?").run(cutoff);
4383
- this.db.exec("COMMIT");
4384
- } catch (error) {
4385
- try {
4386
- this.db.exec("ROLLBACK");
4387
- } catch {
4388
- }
4389
- return {
4390
- ...empty,
4391
- errors: [
4392
- {
4393
- file: this.dbPath,
4394
- reason: error instanceof Error ? error.message : String(error)
4395
- }
4396
- ]
4397
- };
4246
+ const agentRange = dayFilter("day");
4247
+ const uniqueAgents = this.db.prepare(`SELECT COUNT(DISTINCT agent_id) n FROM agent_daily${agentRange.where}`).get(...agentRange.params).n;
4248
+ const requestRange = dayFilter("day");
4249
+ const logicalRequests = this.db.prepare(
4250
+ `SELECT COUNT(DISTINCT logical_request_id) n FROM logical_request_daily${requestRange.where}`
4251
+ ).get(...requestRange.params).n;
4252
+ const fileRange = dayFilter("day");
4253
+ const uniqueFiles = this.db.prepare(`SELECT COUNT(DISTINCT path_key) n FROM file_seen_daily${fileRange.where}`).get(...fileRange.params).n;
4254
+ const costRange = dayFilter("day");
4255
+ const cost = this.db.prepare(`SELECT COALESCE(SUM(cost),0) c FROM token_cost${costRange.where}`).get(...costRange.params).c;
4256
+ return {
4257
+ logicalRequests: Number(logicalRequests),
4258
+ modelAttempts: Number(provider.attempts),
4259
+ completedAttempts: Number(provider.completed),
4260
+ failedAttempts: Number(provider.failed),
4261
+ scheduledRetries: Number(provider.retries),
4262
+ fallbacks: Number(provider.fallbacks),
4263
+ providers: Number(provider.providers),
4264
+ models: Number(provider.models),
4265
+ inputTokens: Number(provider.inputTokens),
4266
+ outputTokens: Number(provider.outputTokens),
4267
+ cacheReadTokens: Number(provider.cacheReadTokens),
4268
+ cacheWriteTokens: Number(provider.cacheWriteTokens),
4269
+ estimatedCostUsd: Number(cost),
4270
+ providerAvgDurationMs: Number(provider.durationCount) > 0 ? Number(provider.durationTotal) / Number(provider.durationCount) : 0,
4271
+ // True p95 needs a retained distribution; this per-day aggregate only
4272
+ // keeps sum/max/count, so approximate with the observed max rather
4273
+ // than adding a per-attempt histogram write (would add the same kind
4274
+ // of per-event overhead this whole effort is trying to remove).
4275
+ providerP95DurationMs: Number(provider.durationMax),
4276
+ toolCalls: Number(counters.toolCalls),
4277
+ completedTools: Number(counters.completedTools),
4278
+ failedTools: Number(counters.failedTools),
4279
+ toolAvgDurationMs: Number(counters.toolDurationCount) > 0 ? Number(counters.toolDurationTotal) / Number(counters.toolDurationCount) : 0,
4280
+ processes: Number(counters.processes),
4281
+ failedProcesses: Number(counters.failedProcesses),
4282
+ fileEvents: Number(counters.fileEvents),
4283
+ uniqueFiles: Number(uniqueFiles),
4284
+ agentEvents: Number(counters.agentEvents),
4285
+ uniqueAgents: Number(uniqueAgents),
4286
+ decisions: Number(counters.decisions),
4287
+ escalations: Number(counters.escalations),
4288
+ failures: Number(counters.failures),
4289
+ cancellations: Number(counters.cancellations),
4290
+ families,
4291
+ failuresByFamily
4292
+ };
4293
+ }
4294
+ // ─── Ingest internals ─────────────────────────────────────────────────────
4295
+ ensureSchema() {
4296
+ const version = this.db.prepare("PRAGMA user_version").get().user_version;
4297
+ if (version !== 0 && version !== SCHEMA_VERSION2) {
4298
+ this.db.exec(
4299
+ "DROP TABLE IF EXISTS ingest_state; DROP TABLE IF EXISTS provider_daily;DROP TABLE IF EXISTS task_outcomes; DROP TABLE IF EXISTS file_lineage;DROP TABLE IF EXISTS token_cost; DROP TABLE IF EXISTS daily_counters;DROP TABLE IF EXISTS family_daily; DROP TABLE IF EXISTS agent_daily;DROP TABLE IF EXISTS logical_request_daily; DROP TABLE IF EXISTS file_seen_daily;"
4300
+ );
4301
+ }
4302
+ this.db.exec(`
4303
+ CREATE TABLE IF NOT EXISTS ingest_state (
4304
+ file TEXT PRIMARY KEY,
4305
+ bytes INTEGER NOT NULL
4306
+ );
4307
+ CREATE TABLE IF NOT EXISTS provider_daily (
4308
+ day TEXT NOT NULL,
4309
+ provider_id TEXT NOT NULL,
4310
+ model_id TEXT NOT NULL,
4311
+ attempts INTEGER NOT NULL DEFAULT 0,
4312
+ completed INTEGER NOT NULL DEFAULT 0,
4313
+ failed INTEGER NOT NULL DEFAULT 0,
4314
+ retries INTEGER NOT NULL DEFAULT 0,
4315
+ fallbacks INTEGER NOT NULL DEFAULT 0,
4316
+ input_tokens INTEGER NOT NULL DEFAULT 0,
4317
+ output_tokens INTEGER NOT NULL DEFAULT 0,
4318
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
4319
+ cache_write_tokens INTEGER NOT NULL DEFAULT 0,
4320
+ duration_ms_total REAL NOT NULL DEFAULT 0,
4321
+ duration_ms_max REAL NOT NULL DEFAULT 0,
4322
+ duration_count INTEGER NOT NULL DEFAULT 0,
4323
+ PRIMARY KEY (day, provider_id, model_id)
4324
+ );
4325
+ CREATE TABLE IF NOT EXISTS task_outcomes (
4326
+ task_id TEXT PRIMARY KEY,
4327
+ run_id TEXT NOT NULL DEFAULT '',
4328
+ board_id TEXT NOT NULL DEFAULT '',
4329
+ session_id TEXT NOT NULL DEFAULT '',
4330
+ agent_id TEXT NOT NULL DEFAULT '',
4331
+ status TEXT NOT NULL DEFAULT 'started',
4332
+ started_at TEXT,
4333
+ ended_at TEXT,
4334
+ duration_ms REAL,
4335
+ retries INTEGER NOT NULL DEFAULT 0,
4336
+ verification_failures INTEGER NOT NULL DEFAULT 0
4337
+ );
4338
+ CREATE TABLE IF NOT EXISTS file_lineage (
4339
+ event_id TEXT PRIMARY KEY,
4340
+ path TEXT NOT NULL,
4341
+ path_key TEXT NOT NULL,
4342
+ operation TEXT NOT NULL,
4343
+ occurred_at TEXT NOT NULL,
4344
+ session_id TEXT NOT NULL DEFAULT '',
4345
+ agent_id TEXT NOT NULL DEFAULT '',
4346
+ task_id TEXT NOT NULL DEFAULT '',
4347
+ board_id TEXT NOT NULL DEFAULT '',
4348
+ run_id TEXT NOT NULL DEFAULT '',
4349
+ tool_name TEXT NOT NULL DEFAULT '',
4350
+ provider_id TEXT NOT NULL DEFAULT '',
4351
+ model_id TEXT NOT NULL DEFAULT '',
4352
+ source TEXT NOT NULL DEFAULT ''
4353
+ );
4354
+ -- Lookups filter on the case-normalized path_key (matching the query
4355
+ -- engine); the path column retains original casing for display.
4356
+ CREATE INDEX IF NOT EXISTS idx_file_lineage_path ON file_lineage(path_key, occurred_at);
4357
+ CREATE INDEX IF NOT EXISTS idx_file_lineage_task ON file_lineage(task_id);
4358
+ CREATE TABLE IF NOT EXISTS token_cost (
4359
+ scope_key TEXT PRIMARY KEY,
4360
+ day TEXT NOT NULL,
4361
+ occurred_at TEXT NOT NULL,
4362
+ sequence INTEGER NOT NULL,
4363
+ cost REAL NOT NULL
4364
+ );
4365
+ -- Backing store for defaultSummary(): per-day scalar counters plus
4366
+ -- dedup sets, populated for every ingested event (not just the
4367
+ -- provider/task/file families above).
4368
+ CREATE TABLE IF NOT EXISTS daily_counters (
4369
+ day TEXT PRIMARY KEY,
4370
+ tool_calls INTEGER NOT NULL DEFAULT 0,
4371
+ completed_tools INTEGER NOT NULL DEFAULT 0,
4372
+ failed_tools INTEGER NOT NULL DEFAULT 0,
4373
+ tool_duration_ms_total REAL NOT NULL DEFAULT 0,
4374
+ tool_duration_ms_max REAL NOT NULL DEFAULT 0,
4375
+ tool_duration_count INTEGER NOT NULL DEFAULT 0,
4376
+ processes INTEGER NOT NULL DEFAULT 0,
4377
+ failed_processes INTEGER NOT NULL DEFAULT 0,
4378
+ file_events_all INTEGER NOT NULL DEFAULT 0,
4379
+ decisions INTEGER NOT NULL DEFAULT 0,
4380
+ escalations INTEGER NOT NULL DEFAULT 0,
4381
+ agent_events INTEGER NOT NULL DEFAULT 0,
4382
+ failures INTEGER NOT NULL DEFAULT 0,
4383
+ cancellations INTEGER NOT NULL DEFAULT 0
4384
+ );
4385
+ CREATE TABLE IF NOT EXISTS family_daily (
4386
+ day TEXT NOT NULL,
4387
+ family TEXT NOT NULL,
4388
+ count INTEGER NOT NULL DEFAULT 0,
4389
+ failure_count INTEGER NOT NULL DEFAULT 0,
4390
+ PRIMARY KEY (day, family)
4391
+ );
4392
+ CREATE TABLE IF NOT EXISTS agent_daily (day TEXT NOT NULL, agent_id TEXT NOT NULL, PRIMARY KEY (day, agent_id));
4393
+ CREATE TABLE IF NOT EXISTS logical_request_daily (day TEXT NOT NULL, logical_request_id TEXT NOT NULL, PRIMARY KEY (day, logical_request_id));
4394
+ CREATE TABLE IF NOT EXISTS file_seen_daily (day TEXT NOT NULL, path_key TEXT NOT NULL, PRIMARY KEY (day, path_key));
4395
+ PRAGMA user_version = ${SCHEMA_VERSION2};
4396
+ `);
4397
+ }
4398
+ loadOffsets() {
4399
+ const rows = this.db.prepare("SELECT file, bytes FROM ingest_state").all();
4400
+ return new Map(rows.map((row) => [row.file, Number(row.bytes)]));
4401
+ }
4402
+ pruneOffsets(existingFiles) {
4403
+ const keep = new Set(
4404
+ existingFiles.map((file) => normalizeKey(path13.relative(this.directory, file)))
4405
+ );
4406
+ for (const row of this.db.prepare("SELECT file FROM ingest_state").all()) {
4407
+ if (row.file.startsWith(SQLITE_SOURCE_PREFIX)) continue;
4408
+ if (!keep.has(row.file))
4409
+ this.db.prepare("DELETE FROM ingest_state WHERE file = ?").run(row.file);
4398
4410
  }
4399
- this.anchors.clear();
4400
- this.retainedEventCount = Math.max(0, this.retainedEventCount - count);
4401
- return { ...empty, deletedCount: count };
4402
4411
  }
4403
4412
  /**
4404
- * Run one day family's legacy import inside its own transaction.
4405
- *
4406
- * Deliberately separate from `appendBatch`: the append path *computes*
4407
- * `sequence`, `previousHash` and `hash`, while an import must carry them over
4408
- * untouched. Fusing the two would put a code path one refactor away from
4409
- * re-hashing historical events, which is the one change that silently
4410
- * destroys their tamper evidence.
4413
+ * Fold everything the SQLite journal holds past this store's per-day cursor.
4411
4414
  *
4412
- * The transaction is scoped to a single family because chains are: `sequence`
4413
- * restarts at 1 each day, so one day's break says nothing about the next
4414
- * day's integrity. A whole-journal transaction made every future day hostage
4415
- * to the worst day on disk — one corrupt family and the daemon could never
4416
- * open its store again. The family is still all-or-nothing: a break rolls
4417
- * back that day entirely, so no partial chain is ever visible.
4415
+ * Opened read-only on its own connection: the journal runs in WAL, so this
4416
+ * never blocks the daemon writing to it, and metrics are best-effort a
4417
+ * journal that cannot be opened (mid-migration, absent, locked) leaves the
4418
+ * cursors untouched and the next refresh retries.
4419
+ *
4420
+ * Rows the journal has already evicted are simply not seen. That is the
4421
+ * intended split of responsibilities: the journal is a bounded ring, and this
4422
+ * store is where an aggregate outlives the raw event it came from — which
4423
+ * only holds if refresh runs more often than the ring turns over.
4418
4424
  */
4419
- async runFamilyImport(load) {
4420
- const insert = this.db.prepare(
4421
- `INSERT INTO events (
4422
- day, sequence, event_id, hash, previous_hash, occurred_at, event_type, outcome,
4423
- project_id, session_id, agent_id, task_id, trace_id, logical_request_id,
4424
- resource_kind, resource_id, resource_path, duration_ns, payload
4425
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
4426
- );
4427
- const checkpoint = this.db.prepare(
4428
- `INSERT INTO chain_checkpoint (day, sequence, hash) VALUES (?, ?, ?)
4429
- ON CONFLICT(day) DO UPDATE SET sequence = excluded.sequence, hash = excluded.hash`
4430
- );
4431
- this.db.exec("BEGIN IMMEDIATE");
4432
- let retainedCountAfterCommit = this.retainedEventCount;
4425
+ ingestSqliteJournal(offsets, result) {
4426
+ const journalPath = path13.join(this.directory, CHRONICLE_SQLITE_FILE);
4427
+ let source;
4433
4428
  try {
4434
- this.assertWithinByteQuota([]);
4435
- await load({
4436
- insert: (day, event) => {
4437
- const row = projectEvent(event);
4438
- insert.run(
4439
- day,
4440
- event.sequence,
4441
- event.eventId,
4442
- event.hash,
4443
- event.previousHash,
4444
- row.occurredAt,
4445
- event.eventType,
4446
- row.outcome,
4447
- row.projectId,
4448
- row.sessionId,
4449
- row.agentId,
4450
- row.taskId,
4451
- row.traceId,
4452
- row.logicalRequestId,
4453
- row.resourceKind,
4454
- row.resourceId,
4455
- row.resourcePath,
4456
- row.durationNs,
4457
- JSON.stringify(event)
4458
- );
4459
- },
4460
- checkpoint: (day, sequence, hash) => {
4461
- checkpoint.run(day, sequence, hash);
4429
+ source = new (loadDatabaseSync2())(journalPath, { readOnly: true });
4430
+ } catch {
4431
+ return;
4432
+ }
4433
+ try {
4434
+ const days = source.prepare("SELECT DISTINCT day FROM events ORDER BY day").all();
4435
+ const read = source.prepare(
4436
+ "SELECT sequence, payload FROM events WHERE day = ? AND sequence > ? ORDER BY sequence LIMIT ?"
4437
+ );
4438
+ const writeCursor = this.db.prepare(
4439
+ "INSERT INTO ingest_state (file, bytes) VALUES (?, ?) ON CONFLICT(file) DO UPDATE SET bytes = excluded.bytes"
4440
+ );
4441
+ for (const { day } of days) {
4442
+ const key = `${SQLITE_SOURCE_PREFIX}${day}`;
4443
+ const from = offsets.get(key) ?? 0;
4444
+ let cursor = from;
4445
+ this.db.exec("BEGIN");
4446
+ try {
4447
+ for (; ; ) {
4448
+ const rows = read.all(day, cursor, SQLITE_INGEST_BATCH);
4449
+ if (rows.length === 0) break;
4450
+ for (const row of rows) {
4451
+ try {
4452
+ this.ingestEvent(JSON.parse(row.payload));
4453
+ result.ingestedEvents++;
4454
+ } catch {
4455
+ result.invalidLines++;
4456
+ }
4457
+ result.ingestedBytes += row.payload.length;
4458
+ cursor = Number(row.sequence);
4459
+ }
4460
+ if (rows.length < SQLITE_INGEST_BATCH) break;
4461
+ }
4462
+ if (cursor > from) writeCursor.run(key, cursor);
4463
+ this.db.exec("COMMIT");
4464
+ } catch (error) {
4465
+ this.db.exec("ROLLBACK");
4466
+ throw error;
4462
4467
  }
4463
- });
4464
- retainedCountAfterCommit = this.enforceEventLimitWithinTransaction();
4465
- this.assertActualAllocationWithinQuota();
4466
- this.db.exec("COMMIT");
4467
- } catch (error) {
4468
+ if (cursor > from) result.sourceFiles++;
4469
+ }
4470
+ } catch {
4471
+ } finally {
4472
+ source.close();
4473
+ }
4474
+ }
4475
+ /** Read complete lines appended after `consumed` bytes. The trailing
4476
+ * partial line of an actively-written partition is left for the next
4477
+ * refresh — `ingest_state.bytes` only ever advances past full lines. */
4478
+ async ingestFile(file, key, consumed, result) {
4479
+ let handle;
4480
+ try {
4481
+ handle = await fs8.open(file, "r");
4482
+ } catch {
4483
+ return false;
4484
+ }
4485
+ try {
4486
+ const size = (await handle.stat()).size;
4487
+ if (size <= consumed) return false;
4488
+ let position = consumed;
4489
+ let remainder = Buffer.alloc(0);
4490
+ let advanced = consumed;
4491
+ this.db.exec("BEGIN");
4468
4492
  try {
4493
+ while (position < size) {
4494
+ const length = Math.min(READ_CHUNK_BYTES, size - position);
4495
+ const buffer = Buffer.allocUnsafe(length);
4496
+ const { bytesRead } = await handle.read(buffer, 0, length, position);
4497
+ if (bytesRead <= 0) break;
4498
+ position += bytesRead;
4499
+ const data = remainder.length > 0 ? Buffer.concat([remainder, buffer.subarray(0, bytesRead)]) : buffer.subarray(0, bytesRead);
4500
+ const lastNewline = data.lastIndexOf(10);
4501
+ if (lastNewline < 0) {
4502
+ remainder = Buffer.from(data);
4503
+ continue;
4504
+ }
4505
+ for (const line of data.subarray(0, lastNewline).toString("utf8").split("\n")) {
4506
+ const trimmed = line.trim();
4507
+ if (!trimmed) continue;
4508
+ try {
4509
+ this.ingestEvent(JSON.parse(trimmed));
4510
+ result.ingestedEvents++;
4511
+ } catch {
4512
+ result.invalidLines++;
4513
+ }
4514
+ }
4515
+ advanced += lastNewline + 1;
4516
+ remainder = Buffer.from(data.subarray(lastNewline + 1));
4517
+ }
4518
+ this.db.prepare(
4519
+ "INSERT INTO ingest_state (file, bytes) VALUES (?, ?) ON CONFLICT(file) DO UPDATE SET bytes = excluded.bytes"
4520
+ ).run(key, advanced);
4521
+ this.db.exec("COMMIT");
4522
+ } catch (error) {
4469
4523
  this.db.exec("ROLLBACK");
4470
- } catch {
4524
+ throw error;
4471
4525
  }
4472
- throw this.normalizeQuotaError(error);
4526
+ result.ingestedBytes += advanced - consumed;
4527
+ return advanced > consumed;
4473
4528
  } finally {
4474
- this.anchors.clear();
4529
+ await handle.close();
4475
4530
  }
4476
- this.retainedEventCount = retainedCountAfterCommit;
4477
- }
4478
- /**
4479
- * A read engine over this journal's own connection.
4480
- *
4481
- * Sharing the connection rather than opening a second one keeps the
4482
- * single-writer guarantee intact and means a reader can never observe a
4483
- * half-applied batch: SQLite serialises statements on one handle.
4484
- */
4485
- queryEngine(options) {
4486
- return new ChronicleSqliteQueryEngine(this.db, options);
4487
- }
4488
- /** Has the legacy JSONL import already run? */
4489
- hasImportedLegacyJournal() {
4490
- return this.readMeta(LEGACY_JSONL_MIGRATION_KEY) !== void 0;
4491
4531
  }
4492
- markLegacyJournalImported() {
4493
- this.db.prepare(
4494
- `INSERT INTO chronicle_meta (key, value) VALUES (?, 'done')
4495
- ON CONFLICT(key) DO UPDATE SET value = 'done'`
4496
- ).run(LEGACY_JSONL_MIGRATION_KEY);
4532
+ ingestEvent(event) {
4533
+ if (typeof event?.eventType !== "string" || !event.scope) return;
4534
+ this.ingestDailyCounters(event);
4535
+ const type = event.eventType;
4536
+ if (type.startsWith("provider.attempt.") || type === "provider.fallback") {
4537
+ this.ingestProvider(event);
4538
+ } else if (type === "token.accounted") {
4539
+ this.ingestTokenCost(event);
4540
+ } else if (/^(?:sdd|subagent|kanban)\.task[._]/.test(type)) {
4541
+ this.ingestTask(event);
4542
+ } else if (type === "file.event" || /^file\.(?:tool|external)\./.test(type)) {
4543
+ this.ingestFileEvent(event);
4544
+ }
4497
4545
  }
4498
- /**
4499
- * Record the day families the import refused to move.
4500
- *
4501
- * Persisted rather than merely logged because the import runs once: after the
4502
- * marker is set nothing re-reads the JSONL, so this row is the only surviving
4503
- * evidence that a day was dropped. Health reports read it back to say
4504
- * "degraded, and here is exactly what is missing" instead of quietly serving
4505
- * a journal with a hole in it.
4506
- */
4507
- recordQuarantinedFamilies(families) {
4508
- if (families.length === 0) return;
4546
+ /** Runs for every ingested event (not just the type-specific branches
4547
+ * below) mirrors query.ts's updateSummary() closely enough that
4548
+ * defaultSummary() matches what a raw scan of the same window would say. */
4549
+ ingestDailyCounters(event) {
4550
+ const day = eventDay(event);
4551
+ this.db.prepare("INSERT OR IGNORE INTO daily_counters (day) VALUES (?)").run(day);
4552
+ const bump = (sql, ...params) => this.db.prepare(`UPDATE daily_counters SET ${sql} WHERE day = ?`).run(...params, day);
4553
+ const family = signalFamily(event);
4554
+ const failed = isTerminalFailure(event) ? 1 : 0;
4509
4555
  this.db.prepare(
4510
- `INSERT INTO chronicle_meta (key, value) VALUES (?, ?)
4511
- ON CONFLICT(key) DO UPDATE SET value = excluded.value`
4512
- ).run(LEGACY_JSONL_QUARANTINE_KEY, JSON.stringify(families));
4513
- }
4514
- /**
4515
- * Does this day already hold rows?
4516
- *
4517
- * Each family commits on its own, so an import interrupted between families
4518
- * leaves a database that is complete for the days it reached. `(day,
4519
- * sequence)` is the primary key, so re-inserting one of those days would
4520
- * abort on a constraint violation rather than start over — this is what lets
4521
- * the next run resume at the first day it never got to.
4522
- */
4523
- hasImportedDay(day) {
4524
- const row = this.db.prepare("SELECT 1 AS present FROM events WHERE day = ? LIMIT 1").get(day);
4525
- return row !== void 0;
4556
+ `INSERT INTO family_daily (day, family, count, failure_count) VALUES (?, ?, 1, ?)
4557
+ ON CONFLICT(day, family) DO UPDATE SET count = count + 1, failure_count = failure_count + excluded.failure_count`
4558
+ ).run(day, family, failed);
4559
+ if (failed) bump("failures = failures + 1");
4560
+ if (event.outcome === "cancelled" || event.outcome === "abandoned")
4561
+ bump("cancellations = cancellations + 1");
4562
+ if (family === "agent") bump("agent_events = agent_events + 1");
4563
+ if (event.correlation.logicalRequestId) {
4564
+ this.db.prepare(
4565
+ "INSERT OR IGNORE INTO logical_request_daily (day, logical_request_id) VALUES (?, ?)"
4566
+ ).run(day, event.correlation.logicalRequestId);
4567
+ }
4568
+ if (event.scope.agentId) {
4569
+ this.db.prepare("INSERT OR IGNORE INTO agent_daily (day, agent_id) VALUES (?, ?)").run(day, event.scope.agentId);
4570
+ }
4571
+ const type = event.eventType;
4572
+ if (type === "decision.requested") bump("decisions = decisions + 1");
4573
+ else if (type === "decision.escalated") bump("escalations = escalations + 1");
4574
+ else if (type === "tool.started") bump("tool_calls = tool_calls + 1");
4575
+ else if (type === "tool.executed" || type === "tool.failed") {
4576
+ const dur = durationMs2(event);
4577
+ const durationCount = dur > 0 ? 1 : 0;
4578
+ bump(
4579
+ `${type === "tool.executed" ? "completed_tools" : "failed_tools"} = ${type === "tool.executed" ? "completed_tools" : "failed_tools"} + 1,
4580
+ tool_duration_ms_total = tool_duration_ms_total + ?, tool_duration_ms_max = MAX(tool_duration_ms_max, ?), tool_duration_count = tool_duration_count + ?`,
4581
+ dur,
4582
+ dur,
4583
+ durationCount
4584
+ );
4585
+ } else if (type === "process.started") bump("processes = processes + 1");
4586
+ else if (type === "process.completed" && event.outcome === "failure")
4587
+ bump("failed_processes = failed_processes + 1");
4588
+ if (event.resource?.kind === "file" || type.startsWith("file.")) {
4589
+ bump("file_events_all = file_events_all + 1");
4590
+ if (event.resource?.path) {
4591
+ this.db.prepare("INSERT OR IGNORE INTO file_seen_daily (day, path_key) VALUES (?, ?)").run(day, normalizePathKey(event.resource.path));
4592
+ }
4593
+ }
4526
4594
  }
4527
- /** Day families the legacy import refused to move, oldest first. */
4528
- quarantinedFamilies() {
4529
- const raw = this.readMeta(LEGACY_JSONL_QUARANTINE_KEY);
4530
- if (!raw) return [];
4531
- try {
4532
- const parsed2 = JSON.parse(raw);
4533
- return Array.isArray(parsed2) ? parsed2 : [];
4534
- } catch {
4535
- return [];
4595
+ ingestProvider(event) {
4596
+ const day = eventDay(event);
4597
+ const providerId = event.runtime?.providerId ?? asString(readPath2(event.attributes ?? {}, "from.providerId")) ?? "";
4598
+ const modelId = event.runtime?.modelId ?? asString(readPath2(event.attributes ?? {}, "from.model")) ?? "";
4599
+ if (!providerId && !modelId) return;
4600
+ this.db.prepare("INSERT OR IGNORE INTO provider_daily (day, provider_id, model_id) VALUES (?, ?, ?)").run(day, providerId, modelId);
4601
+ const update = (sql, ...params) => this.db.prepare(
4602
+ `UPDATE provider_daily SET ${sql} WHERE day = ? AND provider_id = ? AND model_id = ?`
4603
+ ).run(...params, day, providerId, modelId);
4604
+ const duration = durationMs2(event);
4605
+ switch (event.eventType) {
4606
+ case "provider.attempt.started":
4607
+ update("attempts = attempts + 1");
4608
+ break;
4609
+ case "provider.attempt.completed":
4610
+ update(
4611
+ "completed = completed + 1, input_tokens = input_tokens + ?, output_tokens = output_tokens + ?, cache_read_tokens = cache_read_tokens + ?, cache_write_tokens = cache_write_tokens + ?, duration_ms_total = duration_ms_total + ?, duration_ms_max = MAX(duration_ms_max, ?), duration_count = duration_count + ?",
4612
+ numberAt2(event, "usage.input"),
4613
+ numberAt2(event, "usage.output"),
4614
+ numberAt2(event, "usage.cacheRead"),
4615
+ numberAt2(event, "usage.cacheWrite"),
4616
+ duration,
4617
+ duration,
4618
+ duration > 0 ? 1 : 0
4619
+ );
4620
+ break;
4621
+ case "provider.attempt.failed":
4622
+ update(
4623
+ "failed = failed + 1, retries = retries + ?, duration_ms_total = duration_ms_total + ?, duration_ms_max = MAX(duration_ms_max, ?), duration_count = duration_count + ?",
4624
+ event.attributes?.retryScheduled === true ? 1 : 0,
4625
+ duration,
4626
+ duration,
4627
+ duration > 0 ? 1 : 0
4628
+ );
4629
+ break;
4630
+ case "provider.fallback":
4631
+ update("fallbacks = fallbacks + 1");
4632
+ break;
4633
+ default:
4634
+ break;
4536
4635
  }
4537
4636
  }
4538
- // ─── internals ────────────────────────────────────────────────────────────
4539
- readMeta(key) {
4540
- const row = this.db.prepare("SELECT value FROM chronicle_meta WHERE key = ?").get(key);
4541
- return row?.value;
4542
- }
4543
- readCheckpoint(day) {
4544
- return this.db.prepare("SELECT sequence, hash FROM chain_checkpoint WHERE day = ?").get(day);
4637
+ ingestTokenCost(event) {
4638
+ const cost = readPath2(event.attributes ?? {}, "cost.total");
4639
+ if (typeof cost !== "number" || !Number.isFinite(cost)) return;
4640
+ const scopeKey2 = `${event.scope.projectId ?? ""}\0${event.scope.sessionId ?? ""}\0${event.scope.agentId ?? ""}`;
4641
+ const occurredAt = event.occurredAt ?? event.observedAt;
4642
+ this.db.prepare(
4643
+ `INSERT INTO token_cost (scope_key, day, occurred_at, sequence, cost) VALUES (?, ?, ?, ?, ?)
4644
+ ON CONFLICT(scope_key) DO UPDATE SET
4645
+ day = excluded.day, occurred_at = excluded.occurred_at,
4646
+ sequence = excluded.sequence, cost = excluded.cost
4647
+ WHERE excluded.occurred_at > token_cost.occurred_at
4648
+ OR (excluded.occurred_at = token_cost.occurred_at AND excluded.sequence > token_cost.sequence)`
4649
+ ).run(scopeKey2, eventDay(event), occurredAt, event.sequence, cost);
4545
4650
  }
4546
- /** Days that currently hold at least one event, oldest first. */
4547
- days() {
4548
- const rows = this.db.prepare("SELECT DISTINCT day FROM events ORDER BY day").all();
4549
- return rows.map((row) => row.day);
4651
+ ingestTask(event) {
4652
+ const attributes = event.attributes ?? {};
4653
+ const taskId = event.scope.taskId ?? stringAt(attributes, "taskId");
4654
+ if (!taskId) return;
4655
+ const occurredAt = event.occurredAt ?? event.observedAt;
4656
+ this.db.prepare("INSERT OR IGNORE INTO task_outcomes (task_id) VALUES (?)").run(taskId);
4657
+ const set = (sql, ...params) => this.db.prepare(`UPDATE task_outcomes SET ${sql} WHERE task_id = ?`).run(...params, taskId);
4658
+ const lineage = [
4659
+ ["run_id", stringAt(attributes, "runId")],
4660
+ ["board_id", event.scope.kanbanBoardId ?? stringAt(attributes, "boardId")],
4661
+ ["session_id", event.scope.sessionId],
4662
+ ["agent_id", event.scope.agentId ?? stringAt(attributes, "subagentId")]
4663
+ ];
4664
+ for (const [column, value] of lineage) {
4665
+ if (value) set(`${column} = ?`, value);
4666
+ }
4667
+ const base = event.eventType.replace(/^(?:sdd|subagent|kanban)\.task[._]/, "");
4668
+ switch (base) {
4669
+ case "started":
4670
+ set("status = 'started', started_at = COALESCE(started_at, ?)", occurredAt);
4671
+ break;
4672
+ case "completed":
4673
+ set(
4674
+ "status = 'completed', ended_at = ?, duration_ms = ?",
4675
+ occurredAt,
4676
+ numberOrDuration(event, attributes)
4677
+ );
4678
+ break;
4679
+ case "failed":
4680
+ set("status = 'failed', ended_at = ?", occurredAt);
4681
+ break;
4682
+ case "retrying":
4683
+ set("retries = retries + 1");
4684
+ break;
4685
+ case "verification_failed":
4686
+ set("verification_failures = verification_failures + 1");
4687
+ break;
4688
+ case "merged":
4689
+ set("status = 'merged'");
4690
+ break;
4691
+ case "conflict":
4692
+ set("status = 'conflict'");
4693
+ break;
4694
+ default:
4695
+ break;
4696
+ }
4550
4697
  }
4551
- /**
4552
- * Resolve the chain head, in the same precedence the JSONL journal uses: the
4553
- * newest event, else the retention checkpoint (every event before it was
4554
- * purged), else genesis.
4555
- */
4556
- readAnchor(day) {
4557
- const cached = this.anchors.get(day);
4558
- if (cached) return cached;
4559
- const last = this.db.prepare("SELECT sequence, hash FROM events WHERE day = ? ORDER BY sequence DESC LIMIT 1").get(day);
4560
- const anchor = last ?? this.readCheckpoint(day) ?? { sequence: 0, hash: GENESIS_HASH };
4561
- this.anchors.set(day, anchor);
4562
- return anchor;
4698
+ ingestFileEvent(event) {
4699
+ const attributes = event.attributes ?? {};
4700
+ const operation = stringAt(attributes, "operation") ?? "";
4701
+ if (!operation || operation === "read") return;
4702
+ const filePath = event.resource?.path ?? stringAt(attributes, "filePath");
4703
+ if (!filePath) return;
4704
+ this.db.prepare(
4705
+ `INSERT OR IGNORE INTO file_lineage
4706
+ (event_id, path, path_key, operation, occurred_at, session_id, agent_id, task_id, board_id, run_id,
4707
+ tool_name, provider_id, model_id, source)
4708
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
4709
+ ).run(
4710
+ event.eventId,
4711
+ normalizeKey(filePath),
4712
+ normalizePathKey(filePath),
4713
+ operation,
4714
+ event.occurredAt ?? event.observedAt,
4715
+ event.scope.sessionId ?? "",
4716
+ event.scope.agentId ?? "",
4717
+ event.scope.taskId ?? stringAt(attributes, "taskId") ?? "",
4718
+ event.scope.kanbanBoardId ?? stringAt(attributes, "boardId") ?? "",
4719
+ stringAt(attributes, "runId") ?? "",
4720
+ stringAt(attributes, "toolName") ?? "",
4721
+ event.runtime?.providerId ?? stringAt(attributes, "provider") ?? "",
4722
+ event.runtime?.modelId ?? stringAt(attributes, "model") ?? "",
4723
+ stringAt(attributes, "source") ?? (event.eventType === "file.event" ? "tool" : "external")
4724
+ );
4563
4725
  }
4564
- ensureSchema() {
4565
- const version = this.db.prepare("PRAGMA user_version").get().user_version;
4566
- this.db.exec(`
4567
- CREATE TABLE IF NOT EXISTS events (
4568
- day TEXT NOT NULL,
4569
- sequence INTEGER NOT NULL,
4570
- event_id TEXT NOT NULL UNIQUE,
4571
- hash TEXT NOT NULL,
4572
- previous_hash TEXT NOT NULL,
4573
- occurred_at TEXT NOT NULL,
4574
- event_type TEXT NOT NULL,
4575
- outcome TEXT,
4576
- project_id TEXT,
4577
- session_id TEXT,
4578
- agent_id TEXT,
4579
- task_id TEXT,
4580
- trace_id TEXT,
4581
- logical_request_id TEXT,
4582
- resource_kind TEXT,
4583
- resource_id TEXT,
4584
- resource_path TEXT,
4585
- duration_ns TEXT,
4586
- payload TEXT NOT NULL,
4587
- PRIMARY KEY (day, sequence)
4588
- );
4589
- CREATE INDEX IF NOT EXISTS events_occurred_at ON events(occurred_at);
4590
- CREATE INDEX IF NOT EXISTS events_type_outcome ON events(event_type, outcome);
4591
- CREATE INDEX IF NOT EXISTS events_session ON events(session_id, day, sequence);
4592
- CREATE INDEX IF NOT EXISTS events_trace ON events(trace_id);
4593
- CREATE INDEX IF NOT EXISTS events_logical_request ON events(logical_request_id);
4594
- CREATE INDEX IF NOT EXISTS events_resource_path ON events(resource_path);
4726
+ };
4727
+ function eventDay(event) {
4728
+ return (event.occurredAt ?? event.observedAt).slice(0, 10);
4729
+ }
4730
+ function durationMs2(event) {
4731
+ const value = Number(event.durationNs ?? 0) / 1e6;
4732
+ return Number.isFinite(value) && value > 0 ? value : 0;
4733
+ }
4734
+ function numberOrDuration(event, attributes) {
4735
+ const explicit = attributes.durationMs;
4736
+ if (typeof explicit === "number" && Number.isFinite(explicit)) return explicit;
4737
+ return durationMs2(event);
4738
+ }
4739
+ function numberAt2(event, dotPath) {
4740
+ const value = readPath2(event.attributes ?? {}, dotPath);
4741
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
4742
+ }
4743
+ function readPath2(value, key) {
4744
+ return key.split(".").reduce(
4745
+ (current, part) => current && typeof current === "object" ? current[part] : void 0,
4746
+ value
4747
+ );
4748
+ }
4749
+ function stringAt(record, key) {
4750
+ const value = record[key];
4751
+ return typeof value === "string" && value.length > 0 ? value : void 0;
4752
+ }
4753
+ function asString(value) {
4754
+ return typeof value === "string" && value.length > 0 ? value : void 0;
4755
+ }
4756
+ function clampLimit(limit, fallback) {
4757
+ if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0) return fallback;
4758
+ return Math.min(Math.floor(limit), 1e4);
4759
+ }
4760
+ function normalizeKey(value) {
4761
+ return value.replaceAll("\\", "/");
4762
+ }
4763
+ function normalizePathKey(value) {
4764
+ return value.replaceAll("\\", "/").replace(/^\.\//, "").toLowerCase();
4765
+ }
4595
4766
 
4596
- CREATE TABLE IF NOT EXISTS chain_checkpoint (
4597
- day TEXT PRIMARY KEY,
4598
- sequence INTEGER NOT NULL,
4599
- hash TEXT NOT NULL
4600
- );
4767
+ // src/chronicle/project-server-endpoint.ts
4768
+ import { createHash as createHash5 } from "node:crypto";
4769
+ import * as fs9 from "node:fs";
4770
+ import * as os3 from "node:os";
4771
+ import * as path14 from "node:path";
4601
4772
 
4602
- CREATE TABLE IF NOT EXISTS chronicle_meta (
4603
- key TEXT PRIMARY KEY,
4604
- value TEXT NOT NULL
4605
- );
4606
- `);
4607
- if (version !== SCHEMA_VERSION2) {
4608
- this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION2}`);
4609
- }
4773
+ // src/chronicle/project-server-protocol.ts
4774
+ var CHRONICLE_PROJECT_SERVER_PROTOCOL_VERSION = 2;
4775
+ var CHRONICLE_PROJECT_SERVER_MAX_FRAME_CHARS = 64 * 1024 * 1024;
4776
+ var CHRONICLE_MAX_APPEND_BATCH = 1e4;
4777
+ function encodeChronicleProjectServerMessage(message) {
4778
+ return `${JSON.stringify(message)}
4779
+ `;
4780
+ }
4781
+
4782
+ // src/chronicle/project-server-endpoint.ts
4783
+ var CHRONICLE_PROJECT_SERVER_METADATA_FILE = "server.json";
4784
+ function normalizedPath(value) {
4785
+ const resolved = path14.resolve(value);
4786
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
4787
+ }
4788
+ function chronicleProjectServerKey(projectDir) {
4789
+ return createHash5("sha256").update(normalizedPath(path14.join(projectDir, "chronicle"))).digest("hex").slice(0, 24);
4790
+ }
4791
+ function chronicleProjectServerEndpoint(projectDir) {
4792
+ const key = chronicleProjectServerKey(projectDir);
4793
+ if (process.platform === "win32") {
4794
+ return `\\\\.\\pipe\\wrongstack-chronicle-v${CHRONICLE_PROJECT_SERVER_PROTOCOL_VERSION}-${key}`;
4795
+ }
4796
+ return path14.join(
4797
+ os3.tmpdir(),
4798
+ `wsch-v${CHRONICLE_PROJECT_SERVER_PROTOCOL_VERSION}`,
4799
+ `${key}.sock`
4800
+ );
4801
+ }
4802
+ function chronicleProjectServerMetadataPath(projectDir) {
4803
+ return path14.join(projectDir, "chronicle", CHRONICLE_PROJECT_SERVER_METADATA_FILE);
4804
+ }
4805
+ function ensureChronicleProjectServerSocketDirectory(endpoint2) {
4806
+ if (process.platform !== "win32") {
4807
+ assertUnixSocketPathWithinLimit(endpoint2, "chronicle");
4808
+ fs9.mkdirSync(path14.dirname(endpoint2), { recursive: true, mode: 448 });
4610
4809
  }
4611
- };
4612
- function projectEvent(event) {
4613
- const occurredAt = event.occurredAt ?? event.observedAt;
4614
- return {
4615
- occurredAt,
4616
- outcome: event.outcome ?? null,
4617
- projectId: event.scope.projectId ?? null,
4618
- sessionId: event.scope.sessionId ?? null,
4619
- agentId: event.scope.agentId ?? null,
4620
- taskId: event.scope.taskId ?? null,
4621
- traceId: event.correlation?.traceId ?? null,
4622
- logicalRequestId: event.correlation?.logicalRequestId ?? null,
4623
- resourceKind: event.resource?.kind ?? null,
4624
- resourceId: event.resource?.id ?? null,
4625
- resourcePath: event.resource?.path ?? null,
4626
- durationNs: event.durationNs ?? null
4627
- };
4628
4810
  }
4629
4811
 
4630
4812
  // src/chronicle/project-server.ts
4631
4813
  var DEFAULT_IDLE_MS = 5 * 6e4;
4632
- var MAX_APPEND_BATCH = 1e4;
4814
+ var MAX_APPEND_BATCH = CHRONICLE_MAX_APPEND_BATCH;
4633
4815
  var MAX_CLIENT_WRITE_BUFFER_BYTES = 8 * 1024 * 1024;
4634
4816
  function parseArgs(argv) {
4635
4817
  const values = /* @__PURE__ */ new Map();
@@ -4967,7 +5149,8 @@ function send(state, message) {
4967
5149
  }
4968
5150
  async function sendAcknowledgement(state, message) {
4969
5151
  const encoded = encodeResponse(state, message);
4970
- if (encoded === void 0) throw new Error("Chronicle client disconnected before acknowledgement");
5152
+ if (encoded === void 0)
5153
+ throw new Error("Chronicle client disconnected before acknowledgement");
4971
5154
  await new Promise((resolve12, reject) => {
4972
5155
  state.socket.write(encoded, (error) => {
4973
5156
  if (error) reject(error);