@origintrail-official/dkg-node-ui 10.0.6 → 10.0.7

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 (26) hide show
  1. package/dist/db.d.ts +123 -1
  2. package/dist/db.d.ts.map +1 -1
  3. package/dist/db.js +642 -24
  4. package/dist/db.js.map +1 -1
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist-ui/assets/{3d-force-graph-Beyt80PS.js → 3d-force-graph-oy_vn0Oy.js} +1 -1
  9. package/dist-ui/assets/{AgentHub-BGlM0TkX.js → AgentHub-BdodC7bc.js} +1 -1
  10. package/dist-ui/assets/{AgentProfilePage-BK2nv-IH.js → AgentProfilePage-DsjmgCNC.js} +1 -1
  11. package/dist-ui/assets/{ApproveWalletsModal-CZrl51Kn.js → ApproveWalletsModal-CoK9PC14.js} +3 -3
  12. package/dist-ui/assets/{ConvictionDetailView-CP2aS_DI.js → ConvictionDetailView-DnpWB1CW.js} +1 -1
  13. package/dist-ui/assets/{Network-DiXYe0aB.js → Network-B1hFeJc_.js} +1 -1
  14. package/dist-ui/assets/{OnChainProvenanceCard-BykYhleb.js → OnChainProvenanceCard-5HtdkdH8.js} +1 -1
  15. package/dist-ui/assets/{Operations-Cgg0jP9m.js → Operations-CD56ktgV.js} +1 -1
  16. package/dist-ui/assets/{PublishingConviction-DGtPr9Cs.js → PublishingConviction-CsGM0rzZ.js} +1 -1
  17. package/dist-ui/assets/{Settings-dSId2mSF.js → Settings-FtB9ej6P.js} +1 -1
  18. package/dist-ui/assets/{ccip-DUvYIt92.js → ccip-C3eQxfL2.js} +1 -1
  19. package/dist-ui/assets/{index-DJH2KcPG.js → index-B0ozkBOt.js} +156 -156
  20. package/dist-ui/assets/{index-DOIKXU1s.js → index-zKvsH-2o.js} +3 -3
  21. package/dist-ui/assets/{jsonld-32FQRO67-XkBT-pPz.js → jsonld-32FQRO67-Ckj0IC7a.js} +2 -2
  22. package/dist-ui/assets/{jsonld-BQ18nHYe.js → jsonld-D76K907R.js} +1 -1
  23. package/dist-ui/assets/{renderer-3d-2EVDZII7-DlPF04Rl.js → renderer-3d-2EVDZII7-C0_bWCJC.js} +2 -2
  24. package/dist-ui/assets/{shikiHighlighter-BhEVT05C.js → shikiHighlighter-FYa041eg.js} +1 -1
  25. package/dist-ui/index.html +1 -1
  26. package/package.json +3 -3
package/dist/db.js CHANGED
@@ -1,20 +1,24 @@
1
1
  import Database from 'better-sqlite3';
2
2
  import { join } from 'node:path';
3
- import { RESPONSE_CACHE_BYTES, } from '@origintrail-official/dkg-core';
3
+ import { RESPONSE_CACHE_BYTES, parseContextGraphJoinPolicyRecord, } from '@origintrail-official/dkg-core';
4
4
  export { SqliteChainEventCursorStore, SqliteContextGraphRegistryScanCursorStore, } from './chain-cursor-stores.js';
5
- const SCHEMA_VERSION = 24;
5
+ const SCHEMA_VERSION = 29;
6
6
  // Default operator retention. Lowered from 90 → 14 days on V15 (2026-05) after
7
7
  // a production incident in which the `logs` table + its FTS5 shadow tables
8
8
  // grew to ~9 GB on a 12-day-old node and corrupted the SQLite page (header
9
9
  // hash mismatch on boot). 90 days had been chosen for "metrics history",
10
10
  // but logs were the dominant grower (~1M rows/12d) and pruning was a no-op
11
- // on any DB younger than 90 days. 14 days is still long enough for any
12
- // realistic operator-driven post-mortem while bounding worst-case growth
13
- // of the (now FTS5-less) logs table to ~150 MB. Operators who want longer
11
+ // on any DB younger than 90 days. 14 days is still long enough for a useful
12
+ // operator-driven post-mortem, but the mainnet sync storm proved that time
13
+ // retention alone cannot bound worst-case growth. Operators who want longer
14
14
  // retention can override via `setRetentionDays()`; the setting is persisted
15
- // in the `settings` table and re-read on next boot.
15
+ // in the `settings` table and re-read on next boot. Time alone is not a hard
16
+ // size bound during a log storm, so routine info/debug rows also have a count
17
+ // ceiling. Warning/error rows keep the full operator-selected time window.
16
18
  const DEFAULT_RETENTION_DAYS = 14;
17
19
  const LEGACY_IMPLICIT_RETENTION_DAYS = 90;
20
+ const DEFAULT_ROUTINE_LOG_ROW_CAP = 1_000_000;
21
+ const DEFAULT_LOG_VOLUME_PRUNE_BATCH_ROWS = 25_000;
18
22
  const LOGS_VACUUM_DELETE_THRESHOLD = 10_000;
19
23
  // SQLite reports reusable-but-not-yet-reclaimed pages via freelist_count.
20
24
  // With the default 4 KiB page size this is roughly 4 MiB, large enough
@@ -100,10 +104,14 @@ export class DashboardDB {
100
104
  dataDir;
101
105
  retentionDays;
102
106
  explicitRetentionDays;
107
+ routineLogRowCap;
108
+ logVolumePruneBatchRows;
103
109
  constructor(opts) {
104
110
  this.dataDir = opts.dataDir;
105
111
  this.explicitRetentionDays = opts.retentionDays !== undefined;
106
112
  this.retentionDays = opts.retentionDays ?? DEFAULT_RETENTION_DAYS;
113
+ this.routineLogRowCap = Math.max(0, Math.floor(opts.routineLogRowCap ?? DEFAULT_ROUTINE_LOG_ROW_CAP));
114
+ this.logVolumePruneBatchRows = Math.max(1, Math.floor(opts.logVolumePruneBatchRows ?? DEFAULT_LOG_VOLUME_PRUNE_BATCH_ROWS));
107
115
  this._memoTtlMs = resolveCacheTtlMs(opts.cacheTtlMs);
108
116
  const dbPath = join(opts.dataDir, 'node-ui.db');
109
117
  this.db = new Database(dbPath);
@@ -130,8 +138,49 @@ export class DashboardDB {
130
138
  migrate() {
131
139
  const version = this.db.pragma('user_version', { simple: true });
132
140
  const upgradedExistingDb = version > 0 && version < SCHEMA_VERSION;
133
- if (version >= SCHEMA_VERSION)
141
+ const ensureJoinPolicyAuditCapTrigger = () => this.db.exec(`
142
+ CREATE TRIGGER IF NOT EXISTS cap_cg_join_policy_audit_rows
143
+ AFTER INSERT ON context_graph_join_policy_audit
144
+ BEGIN
145
+ DELETE FROM context_graph_join_policy_audit
146
+ WHERE id <= NEW.id - 100000
147
+ AND event_type NOT IN (
148
+ 'join_admission_committed',
149
+ 'join_policy_changed'
150
+ );
151
+ END;
152
+ `);
153
+ const ensureJoinApprovalRepairMarker = () => {
154
+ const table = this.db.prepare(`
155
+ SELECT 1 AS found FROM sqlite_master
156
+ WHERE type = 'table' AND name = 'context_graph_join_approval_ledger'
157
+ `).get();
158
+ if (!table)
159
+ return;
160
+ const columns = this.db.pragma('table_info(context_graph_join_approval_ledger)');
161
+ if (!columns.some((column) => column.name === 'repair_pending')) {
162
+ this.db.exec(`
163
+ ALTER TABLE context_graph_join_approval_ledger
164
+ ADD COLUMN repair_pending INTEGER NOT NULL DEFAULT 0
165
+ CHECK (repair_pending IN (0, 1));
166
+ `);
167
+ }
168
+ this.db.exec(`
169
+ CREATE INDEX IF NOT EXISTS idx_cg_join_approval_ledger_repair
170
+ ON context_graph_join_approval_ledger(
171
+ context_graph_id, request_digest, repair_pending, reserved_at DESC
172
+ );
173
+ `);
174
+ };
175
+ if (version > SCHEMA_VERSION)
134
176
  return;
177
+ if (version === SCHEMA_VERSION) {
178
+ // Repair restored/development databases that carry the current version
179
+ // but lost an idempotent schema adjunct.
180
+ ensureJoinApprovalRepairMarker();
181
+ ensureJoinPolicyAuditCapTrigger();
182
+ return;
183
+ }
135
184
  if (version < 1) {
136
185
  this.db.exec(`
137
186
  CREATE TABLE IF NOT EXISTS metric_snapshots (
@@ -773,6 +822,184 @@ export class DashboardDB {
773
822
  high_seq INTEGER NOT NULL CHECK (high_seq >= 0),
774
823
  updated_at INTEGER NOT NULL
775
824
  );
825
+ `);
826
+ }
827
+ if (version < 25) {
828
+ // Durable security audit for private-CG open enrollment. V25/V26 also
829
+ // used this table as admission state; V27 projects those legacy rows into
830
+ // the typed operational ledger below.
831
+ this.db.exec(`
832
+ CREATE TABLE IF NOT EXISTS context_graph_join_policy_audit (
833
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
834
+ ts INTEGER NOT NULL,
835
+ context_graph_id TEXT NOT NULL,
836
+ event_type TEXT NOT NULL,
837
+ actor TEXT,
838
+ agent_address TEXT,
839
+ outcome TEXT NOT NULL,
840
+ reason TEXT,
841
+ request_digest TEXT,
842
+ policy_version INTEGER,
843
+ details TEXT
844
+ );
845
+ CREATE INDEX IF NOT EXISTS idx_cg_join_policy_audit_ts
846
+ ON context_graph_join_policy_audit(ts);
847
+ CREATE INDEX IF NOT EXISTS idx_cg_join_policy_audit_cg_ts
848
+ ON context_graph_join_policy_audit(context_graph_id, ts);
849
+ CREATE INDEX IF NOT EXISTS idx_cg_join_policy_audit_event_ts
850
+ ON context_graph_join_policy_audit(event_type, ts);
851
+ `);
852
+ }
853
+ if (version < 26) {
854
+ // Bound flood-generated decision noise without letting that same flood
855
+ // erase the two records needed to reconstruct a private-CG admission:
856
+ // who changed the policy, and who was automatically admitted under it.
857
+ // V26 additionally retained live reservations because they still enforced
858
+ // the rolling one-hour ceilings. V27 removes that dependency below.
859
+ //
860
+ // V25 already installed this trigger, so DROP before CREATE is required:
861
+ // CREATE TRIGGER IF NOT EXISTS would silently preserve the old predicate
862
+ // on an upgraded database.
863
+ this.db.exec('DROP TRIGGER IF EXISTS cap_cg_join_policy_audit_rows;');
864
+ }
865
+ if (version < 27) {
866
+ // Operational admission state is deliberately separate from the audit
867
+ // stream. Audit retention and event naming must never alter rate-limit,
868
+ // epoch, or commit-idempotency behaviour.
869
+ this.db.exec(`
870
+ CREATE TABLE IF NOT EXISTS context_graph_join_approval_ledger (
871
+ context_graph_id TEXT NOT NULL,
872
+ request_digest TEXT NOT NULL,
873
+ policy_epoch INTEGER NOT NULL,
874
+ reserved_at INTEGER NOT NULL,
875
+ state TEXT NOT NULL CHECK (state IN ('reserved', 'committed')),
876
+ committed_at INTEGER,
877
+ actor TEXT NOT NULL,
878
+ agent_address TEXT NOT NULL,
879
+ policy_version INTEGER NOT NULL,
880
+ PRIMARY KEY (context_graph_id, request_digest, policy_epoch),
881
+ CHECK (length(context_graph_id) > 0),
882
+ CHECK (length(request_digest) > 0),
883
+ CHECK (typeof(policy_epoch) = 'integer'),
884
+ CHECK (typeof(reserved_at) = 'integer'),
885
+ CHECK (committed_at IS NULL OR typeof(committed_at) = 'integer'),
886
+ CHECK (typeof(policy_version) = 'integer'),
887
+ CHECK (
888
+ (state = 'reserved' AND committed_at IS NULL)
889
+ OR (state = 'committed' AND committed_at IS NOT NULL)
890
+ )
891
+ );
892
+ CREATE INDEX IF NOT EXISTS idx_cg_join_approval_ledger_reserved_at
893
+ ON context_graph_join_approval_ledger(reserved_at);
894
+ CREATE INDEX IF NOT EXISTS idx_cg_join_approval_ledger_cg_reserved_at
895
+ ON context_graph_join_approval_ledger(context_graph_id, reserved_at);
896
+ CREATE INDEX IF NOT EXISTS idx_cg_join_approval_ledger_digest_reserved_at
897
+ ON context_graph_join_approval_ledger(context_graph_id, request_digest, reserved_at DESC);
898
+ CREATE INDEX IF NOT EXISTS idx_cg_join_approval_ledger_committed_at
899
+ ON context_graph_join_approval_ledger(committed_at);
900
+ `);
901
+ // Best-effort one-time projection for V25/V26 databases. Invalid legacy
902
+ // JSON cannot abort startup; only rows with a typed numeric epoch can be
903
+ // authoritative operational state. For duplicate audit reservations,
904
+ // retain the newest row for each (CG, digest, epoch).
905
+ this.db.exec(`
906
+ WITH raw_reservations AS (
907
+ SELECT
908
+ id,
909
+ context_graph_id,
910
+ request_digest,
911
+ CASE
912
+ WHEN json_valid(details)
913
+ AND json_type(details, '$.policyEpoch') IN ('integer', 'real')
914
+ THEN CAST(json_extract(details, '$.policyEpoch') AS INTEGER)
915
+ ELSE NULL
916
+ END AS policy_epoch,
917
+ ts AS reserved_at,
918
+ COALESCE(actor, '') AS actor,
919
+ COALESCE(agent_address, '') AS agent_address,
920
+ COALESCE(policy_version, 1) AS policy_version
921
+ FROM context_graph_join_policy_audit
922
+ WHERE event_type = 'join_auto_reservation'
923
+ AND outcome = 'reserved'
924
+ AND request_digest IS NOT NULL
925
+ ),
926
+ ranked_reservations AS (
927
+ SELECT *, ROW_NUMBER() OVER (
928
+ PARTITION BY context_graph_id, request_digest, policy_epoch
929
+ ORDER BY reserved_at DESC, id DESC
930
+ ) AS rank
931
+ FROM raw_reservations
932
+ WHERE policy_epoch IS NOT NULL
933
+ ),
934
+ raw_commits AS (
935
+ SELECT
936
+ context_graph_id,
937
+ request_digest,
938
+ CASE
939
+ WHEN json_valid(details)
940
+ AND json_type(details, '$.policyEpoch') IN ('integer', 'real')
941
+ THEN CAST(json_extract(details, '$.policyEpoch') AS INTEGER)
942
+ ELSE NULL
943
+ END AS policy_epoch,
944
+ MIN(ts) AS committed_at
945
+ FROM context_graph_join_policy_audit
946
+ WHERE event_type = 'join_admission_committed'
947
+ AND outcome = 'approved'
948
+ AND request_digest IS NOT NULL
949
+ GROUP BY context_graph_id, request_digest, policy_epoch
950
+ )
951
+ INSERT OR IGNORE INTO context_graph_join_approval_ledger (
952
+ context_graph_id, request_digest, policy_epoch, reserved_at,
953
+ state, committed_at, actor, agent_address, policy_version
954
+ )
955
+ SELECT
956
+ reservation.context_graph_id,
957
+ reservation.request_digest,
958
+ reservation.policy_epoch,
959
+ reservation.reserved_at,
960
+ CASE WHEN commit_row.committed_at IS NULL THEN 'reserved' ELSE 'committed' END,
961
+ commit_row.committed_at,
962
+ reservation.actor,
963
+ reservation.agent_address,
964
+ reservation.policy_version
965
+ FROM ranked_reservations AS reservation
966
+ LEFT JOIN raw_commits AS commit_row
967
+ ON commit_row.context_graph_id = reservation.context_graph_id
968
+ AND commit_row.request_digest = reservation.request_digest
969
+ AND commit_row.policy_epoch = reservation.policy_epoch
970
+ WHERE reservation.rank = 1;
971
+ `);
972
+ // V26's trigger protected live reservation audit rows because they were
973
+ // operational state. The ledger makes that coupling obsolete, so replace
974
+ // the trigger while preserving the flood-resistant proof exemptions.
975
+ this.db.exec('DROP TRIGGER IF EXISTS cap_cg_join_policy_audit_rows;');
976
+ }
977
+ if (version < 28) {
978
+ // A reservation alone does not prove that admission crossed its
979
+ // membership boundary. Persist that distinction so an exact retry can
980
+ // finish moderation/audit after delegation expiry or daemon restart.
981
+ ensureJoinApprovalRepairMarker();
982
+ }
983
+ // Keep this repair outside the version gate. Restored/development DBs can
984
+ // carry the current user_version while missing a trigger; recreating it is
985
+ // idempotent and keeps the audit bound fail-closed on every open.
986
+ ensureJoinPolicyAuditCapTrigger();
987
+ if (version < 29) {
988
+ this.db.exec(`
989
+ CREATE TABLE IF NOT EXISTS vm_reconcile_negative_cache (
990
+ cache_key TEXT PRIMARY KEY,
991
+ context_graph_id TEXT NOT NULL,
992
+ failures INTEGER NOT NULL,
993
+ next_retry_at INTEGER NOT NULL,
994
+ swm_gen TEXT NOT NULL,
995
+ candidate_namespaces TEXT NOT NULL,
996
+ peer_topology_key TEXT NOT NULL,
997
+ updated_at INTEGER NOT NULL
998
+ );
999
+ CREATE INDEX IF NOT EXISTS idx_vm_reconcile_negative_cg
1000
+ ON vm_reconcile_negative_cache(context_graph_id);
1001
+ CREATE INDEX IF NOT EXISTS idx_vm_reconcile_negative_retry
1002
+ ON vm_reconcile_negative_cache(next_retry_at);
776
1003
  `);
777
1004
  }
778
1005
  this.db.pragma(`user_version = ${SCHEMA_VERSION}`);
@@ -807,7 +1034,21 @@ export class DashboardDB {
807
1034
  this.db.exec(`DELETE FROM chat_persistence_jobs WHERE updated_at < ${cutoff} AND status IN ('stored', 'failed')`);
808
1035
  this.db.exec(`DELETE FROM notifications WHERE ts < ${cutoff}`);
809
1036
  this.db.exec(`DELETE FROM replication_events WHERE ts < ${cutoff}`);
1037
+ this.db.exec(`DELETE FROM context_graph_join_policy_audit WHERE ts < ${cutoff}`);
1038
+ // The admission ledger has its own typed lifetime. It intentionally does
1039
+ // not depend on whether the corresponding audit rows still exist. Preserve
1040
+ // the historical retention envelope for durable commit idempotency while
1041
+ // allowing fully expired reservation/commit state to age out.
1042
+ this.db.prepare(`
1043
+ DELETE FROM context_graph_join_approval_ledger
1044
+ WHERE reserved_at < ?
1045
+ AND (committed_at IS NULL OR committed_at < ?)
1046
+ `).run(cutoff, cutoff);
810
1047
  this.db.prepare(`DELETE FROM sync_checkpoints WHERE expires_at < ?`).run(Date.now());
1048
+ // Reconcile negatives are accelerators, never historical records. Once the
1049
+ // retry window elapses they must not survive indefinitely or accumulate one
1050
+ // row per previously-seen KA across restarts.
1051
+ this.db.prepare(`DELETE FROM vm_reconcile_negative_cache WHERE next_retry_at < ?`).run(Date.now());
811
1052
  // Universal Messenger idempotency table. Shorter TTL than the
812
1053
  // operator retention: no realistic dedup window extends beyond
813
1054
  // a day. The protocol_outbox table is intentionally not pruned
@@ -815,20 +1056,12 @@ export class DashboardDB {
815
1056
  // SqliteProtocolOutboxStore.dropExpired().
816
1057
  const messengerCutoff = Date.now() - 24 * 60 * 60 * 1000;
817
1058
  this.db.exec(`DELETE FROM message_idempotency WHERE ts < ${messengerCutoff}`);
818
- // Reclaim free pages from the file. Without this, the SQLite file
819
- // size only ever grows DELETE / DROP just marks pages reusable,
820
- // it does not return them to the OS. Vacuum when prune removed a
821
- // meaningful number of log rows, or when a previous migration/drop
822
- // left a large freelist behind without deleting any retained logs.
823
- const freePages = Number(this.db.pragma('freelist_count', { simple: true }) ?? 0);
824
- if (logsDeleted > LOGS_VACUUM_DELETE_THRESHOLD || freePages > VACUUM_FREE_PAGE_THRESHOLD) {
825
- try {
826
- this.db.exec(`VACUUM`);
827
- }
828
- catch {
829
- // VACUUM requires an exclusive lock. If another connection is
830
- // holding the DB open we skip and retry on the next prune.
831
- }
1059
+ // Avoid rebuilding an oversized DB at startup while millions of routine
1060
+ // rows are still live. The daemon trims that backlog in bounded batches;
1061
+ // the final batch performs one compacting VACUUM. Databases already under
1062
+ // the cap retain the established time-prune reclamation behaviour.
1063
+ if (!this.hasRoutineLogOverflow()) {
1064
+ this.reclaimFreePagesIfNeeded(logsDeleted > LOGS_VACUUM_DELETE_THRESHOLD);
832
1065
  }
833
1066
  // Return the WAL file itself to the OS. journal_size_limit bounds it
834
1067
  // in steady state, but a TRUNCATE checkpoint here shrinks it promptly
@@ -836,6 +1069,96 @@ export class DashboardDB {
836
1069
  // which rewrites the whole DB through the WAL and momentarily grows
837
1070
  // it. Runs unconditionally — independent of the VACUUM gate — because
838
1071
  // an idle node still wants its -wal reclaimed.
1072
+ this.truncateWal('prune');
1073
+ }
1074
+ /**
1075
+ * Remove one bounded batch of the oldest routine (non-warning/error) logs.
1076
+ * A count cap complements time retention: a high-rate sync storm can create
1077
+ * millions of rows inside a single day, long before a 14-day cutoff applies.
1078
+ *
1079
+ * Deletion is deliberately incremental so an upgrade does not block node
1080
+ * startup on a multi-GB transaction. Once the backlog reaches the cap, one
1081
+ * VACUUM returns the accumulated free pages to the OS and the file shrinks.
1082
+ */
1083
+ pruneLogVolumeBatch() {
1084
+ const overflowCutoff = this.routineLogOverflowCutoff();
1085
+ if (overflowCutoff === null) {
1086
+ const reclaim = this.reclaimFreePagesIfNeeded(false);
1087
+ if (!reclaim.reclaimPending)
1088
+ this.truncateWal('log-volume prune');
1089
+ return {
1090
+ deleted: 0,
1091
+ status: reclaim.reclaimPending
1092
+ ? 'reclaim-pending'
1093
+ : reclaim.compacted
1094
+ ? 'done-compacted'
1095
+ : 'done',
1096
+ };
1097
+ }
1098
+ const deleted = this.db.prepare(`
1099
+ DELETE FROM logs
1100
+ WHERE id IN (
1101
+ SELECT id
1102
+ FROM logs
1103
+ WHERE id <= @cutoff
1104
+ AND level NOT IN ('warn', 'error')
1105
+ ORDER BY id ASC
1106
+ LIMIT @batchRows
1107
+ )
1108
+ `).run({
1109
+ cutoff: overflowCutoff,
1110
+ batchRows: this.logVolumePruneBatchRows,
1111
+ }).changes;
1112
+ // If the batch filled, conservatively schedule another tick. An exact-size
1113
+ // final batch costs one extra cheap probe before compaction, which is safer
1114
+ // than running a second million-row count after every deletion.
1115
+ const hasMore = deleted === this.logVolumePruneBatchRows;
1116
+ const reclaim = hasMore
1117
+ ? { compacted: false, reclaimPending: false }
1118
+ : this.reclaimFreePagesIfNeeded(deleted > LOGS_VACUUM_DELETE_THRESHOLD);
1119
+ if (!hasMore && !reclaim.reclaimPending)
1120
+ this.truncateWal('log-volume prune');
1121
+ return {
1122
+ deleted,
1123
+ status: hasMore
1124
+ ? 'more'
1125
+ : reclaim.reclaimPending
1126
+ ? 'reclaim-pending'
1127
+ : reclaim.compacted
1128
+ ? 'done-compacted'
1129
+ : 'done',
1130
+ };
1131
+ }
1132
+ routineLogOverflowCutoff() {
1133
+ const row = this.db.prepare(`
1134
+ SELECT id
1135
+ FROM logs
1136
+ WHERE level NOT IN ('warn', 'error')
1137
+ ORDER BY id DESC
1138
+ LIMIT 1 OFFSET ?
1139
+ `).get(this.routineLogRowCap);
1140
+ return row?.id ?? null;
1141
+ }
1142
+ hasRoutineLogOverflow() {
1143
+ return this.routineLogOverflowCutoff() !== null;
1144
+ }
1145
+ reclaimFreePagesIfNeeded(force) {
1146
+ // DELETE / DROP only marks pages reusable; VACUUM is what returns them to
1147
+ // the OS. A failed exclusive-lock/disk attempt remains pending for a later
1148
+ // background retry and never prevents the node from running.
1149
+ const freePages = Number(this.db.pragma('freelist_count', { simple: true }) ?? 0);
1150
+ if (!force && freePages <= VACUUM_FREE_PAGE_THRESHOLD) {
1151
+ return { compacted: false, reclaimPending: false };
1152
+ }
1153
+ try {
1154
+ this.db.exec(`VACUUM`);
1155
+ return { compacted: true, reclaimPending: false };
1156
+ }
1157
+ catch {
1158
+ return { compacted: false, reclaimPending: true };
1159
+ }
1160
+ }
1161
+ truncateWal(reason) {
839
1162
  try {
840
1163
  // wal_checkpoint signals reader contention through its result row
841
1164
  // (`busy = 1`), NOT by throwing — so a busy checkpoint leaves the
@@ -845,7 +1168,7 @@ export class DashboardDB {
845
1168
  // rather than indistinguishable from a real one.
846
1169
  const [checkpoint] = this.db.pragma('wal_checkpoint(TRUNCATE)');
847
1170
  if (checkpoint?.busy) {
848
- console.warn(`[DashboardDB] wal_checkpoint(TRUNCATE) busy — WAL not reclaimed this prune ` +
1171
+ console.warn(`[DashboardDB] wal_checkpoint(TRUNCATE) busy — WAL not reclaimed during ${reason} ` +
849
1172
  `(log=${checkpoint.log}, checkpointed=${checkpoint.checkpointed}); retried next prune`);
850
1173
  }
851
1174
  }
@@ -856,7 +1179,7 @@ export class DashboardDB {
856
1179
  // better-sqlite3 change turns lock or I/O errors into throws, the
857
1180
  // stalled WAL reclaim is visible in the daemon log instead of only
858
1181
  // showing up later as unexplained disk growth. Never block prune.
859
- console.warn(`[DashboardDB] wal_checkpoint(TRUNCATE) failed — WAL not reclaimed this prune: ` +
1182
+ console.warn(`[DashboardDB] wal_checkpoint(TRUNCATE) failed — WAL not reclaimed during ${reason}: ` +
860
1183
  `${err instanceof Error ? err.message : String(err)}`);
861
1184
  }
862
1185
  }
@@ -979,7 +1302,302 @@ export class DashboardDB {
979
1302
  return this.stmt('getContextGraphSubscription', 'SELECT * FROM context_graph_subscriptions WHERE context_graph_id = ?').get(contextGraphId);
980
1303
  }
981
1304
  deleteContextGraphSubscription(contextGraphId) {
982
- this.stmt('deleteContextGraphSubscription', 'DELETE FROM context_graph_subscriptions WHERE context_graph_id = ?').run(contextGraphId);
1305
+ const remove = this.db.transaction(() => {
1306
+ this.stmt('deleteContextGraphSubscription', 'DELETE FROM context_graph_subscriptions WHERE context_graph_id = ?').run(contextGraphId);
1307
+ this.stmt('deleteContextGraphReadinessProvenance', 'DELETE FROM settings WHERE key = ?')
1308
+ .run(this.contextGraphReadinessProvenanceKey(contextGraphId));
1309
+ });
1310
+ remove();
1311
+ }
1312
+ /**
1313
+ * CLI-owned, durable provenance for context-graph readiness flags.
1314
+ *
1315
+ * Subscription rows predate per-plane proof and can therefore contain the
1316
+ * v10.0.6 false-ready shape (`synced=1`, `shared_memory_synced=1`) after an
1317
+ * unrelated peer returned an empty response. Keep the proof out of the
1318
+ * agent-owned subscription upsert so older/custom agent stores remain
1319
+ * source-compatible and routine subscription persistence cannot overwrite a
1320
+ * proof established by the daemon's catch-up classifier.
1321
+ */
1322
+ getContextGraphReadinessProvenance(contextGraphId) {
1323
+ const row = this.stmt('getContextGraphReadinessProvenance', 'SELECT value FROM settings WHERE key = ?').get(this.contextGraphReadinessProvenanceKey(contextGraphId));
1324
+ if (!row)
1325
+ return null;
1326
+ try {
1327
+ const parsed = JSON.parse(row.value);
1328
+ if (!Number.isInteger(parsed.version) || (parsed.version ?? 0) < 1)
1329
+ return null;
1330
+ return {
1331
+ version: parsed.version,
1332
+ durableVerified: parsed.durableVerified === true,
1333
+ sharedMemoryVerified: parsed.sharedMemoryVerified === true,
1334
+ updatedAt: typeof parsed.updatedAt === 'number' && Number.isFinite(parsed.updatedAt)
1335
+ ? parsed.updatedAt
1336
+ : 0,
1337
+ };
1338
+ }
1339
+ catch {
1340
+ return null;
1341
+ }
1342
+ }
1343
+ setContextGraphReadinessProvenance(contextGraphId, provenance) {
1344
+ const record = {
1345
+ version: provenance.version,
1346
+ durableVerified: provenance.durableVerified,
1347
+ sharedMemoryVerified: provenance.sharedMemoryVerified,
1348
+ updatedAt: provenance.updatedAt ?? Date.now(),
1349
+ };
1350
+ this.stmt('setContextGraphReadinessProvenance', 'INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(this.contextGraphReadinessProvenanceKey(contextGraphId), JSON.stringify(record));
1351
+ }
1352
+ contextGraphReadinessProvenanceKey(contextGraphId) {
1353
+ return `contextGraphReadiness:${contextGraphId}`;
1354
+ }
1355
+ // --- Private context-graph join policy + audit ---
1356
+ getContextGraphJoinPolicy(contextGraphId) {
1357
+ const row = this.stmt('getContextGraphJoinPolicy', 'SELECT value FROM settings WHERE key = ?').get(this.contextGraphJoinPolicyKey(contextGraphId));
1358
+ if (!row)
1359
+ return null;
1360
+ try {
1361
+ return parseContextGraphJoinPolicyRecord(JSON.parse(row.value), contextGraphId);
1362
+ }
1363
+ catch {
1364
+ // Corrupt policy state is indistinguishable from no policy. The agent's
1365
+ // default is manual, so this is intentionally fail-closed.
1366
+ return null;
1367
+ }
1368
+ }
1369
+ setContextGraphJoinPolicy(record) {
1370
+ this.stmt('setContextGraphJoinPolicy', 'INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(this.contextGraphJoinPolicyKey(record.contextGraphId), JSON.stringify(record));
1371
+ }
1372
+ setContextGraphJoinPolicyWithAudit(record, event) {
1373
+ const transition = this.db.transaction(() => {
1374
+ this.setContextGraphJoinPolicy(record);
1375
+ this.appendContextGraphJoinPolicyAudit(event);
1376
+ });
1377
+ transition();
1378
+ }
1379
+ appendContextGraphJoinPolicyAudit(event) {
1380
+ this.stmt('appendContextGraphJoinPolicyAudit', `
1381
+ INSERT INTO context_graph_join_policy_audit (
1382
+ ts, context_graph_id, event_type, actor, agent_address, outcome,
1383
+ reason, request_digest, policy_version, details
1384
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1385
+ `).run(event.timestamp, event.contextGraphId, event.eventType, event.actor ?? null, event.agentAddress ?? null, event.outcome, event.reason ?? null, event.requestDigest ?? null, event.policyVersion ?? null, event.details ? JSON.stringify(event.details) : null);
1386
+ }
1387
+ reserveContextGraphAutomaticApproval(input) {
1388
+ const reserve = this.db.transaction(() => {
1389
+ const since = input.timestamp - 60 * 60 * 1000;
1390
+ const cgRow = this.stmt('countContextGraphAutomaticApprovalReservations', `
1391
+ SELECT COUNT(*) AS count
1392
+ FROM context_graph_join_approval_ledger
1393
+ WHERE context_graph_id = ?
1394
+ AND reserved_at >= ?
1395
+ `).get(input.contextGraphId, since);
1396
+ const nodeRow = this.stmt('countNodeAutomaticApprovalReservations', `
1397
+ SELECT COUNT(*) AS count
1398
+ FROM context_graph_join_approval_ledger
1399
+ WHERE reserved_at >= ?
1400
+ `).get(since);
1401
+ const contextGraphCount = Number(cgRow?.count ?? 0);
1402
+ const nodeCount = Number(nodeRow?.count ?? 0);
1403
+ const existing = this.stmt('findContextGraphAutomaticApprovalReservation', `
1404
+ SELECT 1 AS found
1405
+ FROM context_graph_join_approval_ledger
1406
+ WHERE context_graph_id = ?
1407
+ AND request_digest = ?
1408
+ AND policy_epoch = ?
1409
+ AND reserved_at >= ?
1410
+ LIMIT 1
1411
+ `).get(input.contextGraphId, input.requestDigest, input.policyEpoch, since);
1412
+ if (existing) {
1413
+ return {
1414
+ allowed: true,
1415
+ contextGraphApprovalsLastHour: contextGraphCount,
1416
+ nodeApprovalsLastHour: nodeCount,
1417
+ };
1418
+ }
1419
+ if (contextGraphCount >= input.contextGraphLimit) {
1420
+ return {
1421
+ allowed: false,
1422
+ contextGraphApprovalsLastHour: contextGraphCount,
1423
+ nodeApprovalsLastHour: nodeCount,
1424
+ reason: 'context-graph-rate-limit',
1425
+ };
1426
+ }
1427
+ if (nodeCount >= input.nodeLimit) {
1428
+ return {
1429
+ allowed: false,
1430
+ contextGraphApprovalsLastHour: contextGraphCount,
1431
+ nodeApprovalsLastHour: nodeCount,
1432
+ reason: 'node-rate-limit',
1433
+ };
1434
+ }
1435
+ // The typed ledger row and its audit projection share one transaction:
1436
+ // failures cannot consume quota without an audit event or emit an audit
1437
+ // reservation without durable operational state. An expired replay of
1438
+ // the same digest+epoch refreshes its reservation timestamp and consumes
1439
+ // the rolling-window quota again, matching the former append-only model.
1440
+ this.stmt('upsertContextGraphAutomaticApprovalReservation', `
1441
+ INSERT INTO context_graph_join_approval_ledger (
1442
+ context_graph_id, request_digest, policy_epoch, reserved_at,
1443
+ state, committed_at, actor, agent_address, policy_version
1444
+ ) VALUES (?, ?, ?, ?, 'reserved', NULL, ?, ?, ?)
1445
+ ON CONFLICT(context_graph_id, request_digest, policy_epoch) DO UPDATE SET
1446
+ reserved_at = excluded.reserved_at,
1447
+ actor = excluded.actor,
1448
+ agent_address = excluded.agent_address,
1449
+ policy_version = excluded.policy_version
1450
+ `).run(input.contextGraphId, input.requestDigest, input.policyEpoch, input.timestamp, input.actor, input.agentAddress, input.policyVersion);
1451
+ this.appendContextGraphJoinPolicyAudit({
1452
+ timestamp: input.timestamp,
1453
+ contextGraphId: input.contextGraphId,
1454
+ eventType: 'join_auto_reservation',
1455
+ actor: input.actor,
1456
+ agentAddress: input.agentAddress,
1457
+ outcome: 'reserved',
1458
+ requestDigest: input.requestDigest,
1459
+ policyVersion: input.policyVersion,
1460
+ details: {
1461
+ contextGraphLimit: input.contextGraphLimit,
1462
+ nodeLimit: input.nodeLimit,
1463
+ policyEpoch: input.policyEpoch,
1464
+ },
1465
+ });
1466
+ return {
1467
+ allowed: true,
1468
+ contextGraphApprovalsLastHour: contextGraphCount + 1,
1469
+ nodeApprovalsLastHour: nodeCount + 1,
1470
+ };
1471
+ });
1472
+ return reserve();
1473
+ }
1474
+ markContextGraphAutomaticApprovalRepairPending(input) {
1475
+ const marked = this.stmt('markContextGraphAutomaticApprovalRepairPending', `
1476
+ UPDATE context_graph_join_approval_ledger
1477
+ SET repair_pending = 1
1478
+ WHERE context_graph_id = ?
1479
+ AND request_digest = ?
1480
+ AND policy_epoch = ?
1481
+ AND state = 'reserved'
1482
+ `).run(input.contextGraphId, input.requestDigest, input.policyEpoch);
1483
+ return marked.changes === 1;
1484
+ }
1485
+ getContextGraphAutomaticApprovalRepair(contextGraphId, requestDigest) {
1486
+ const repair = this.stmt('getContextGraphAutomaticApprovalRepair', `
1487
+ SELECT policy_epoch, actor, agent_address
1488
+ FROM context_graph_join_approval_ledger
1489
+ WHERE context_graph_id = ?
1490
+ AND request_digest = ?
1491
+ AND state = 'reserved'
1492
+ AND repair_pending = 1
1493
+ ORDER BY reserved_at DESC
1494
+ LIMIT 1
1495
+ `).get(contextGraphId, requestDigest);
1496
+ return repair
1497
+ ? {
1498
+ policyEpoch: repair.policy_epoch,
1499
+ actor: repair.actor,
1500
+ agentAddress: repair.agent_address,
1501
+ }
1502
+ : null;
1503
+ }
1504
+ commitContextGraphAutomaticApproval(input) {
1505
+ const commit = this.db.transaction(() => {
1506
+ const reservation = this.stmt('findExactContextGraphAutomaticApprovalReservation', `
1507
+ SELECT context_graph_id, request_digest, policy_epoch, reserved_at,
1508
+ state, committed_at, actor, agent_address, policy_version
1509
+ FROM context_graph_join_approval_ledger
1510
+ WHERE context_graph_id = ?
1511
+ AND request_digest = ?
1512
+ AND policy_epoch = ?
1513
+ LIMIT 1
1514
+ `).get(input.contextGraphId, input.requestDigest, input.policyEpoch);
1515
+ if (!reservation)
1516
+ return false;
1517
+ if (reservation.state === 'committed')
1518
+ return true;
1519
+ const updated = this.stmt('commitContextGraphAutomaticApprovalReservation', `
1520
+ UPDATE context_graph_join_approval_ledger
1521
+ SET state = 'committed', committed_at = ?, repair_pending = 0
1522
+ WHERE context_graph_id = ?
1523
+ AND request_digest = ?
1524
+ AND policy_epoch = ?
1525
+ AND state = 'reserved'
1526
+ `).run(input.timestamp, reservation.context_graph_id, reservation.request_digest, reservation.policy_epoch);
1527
+ if (updated.changes !== 1)
1528
+ return false;
1529
+ this.appendContextGraphJoinPolicyAudit({
1530
+ timestamp: input.timestamp,
1531
+ contextGraphId: input.contextGraphId,
1532
+ eventType: 'join_admission_committed',
1533
+ actor: reservation.actor || input.actor,
1534
+ agentAddress: reservation.agent_address || input.agentAddress,
1535
+ outcome: 'approved',
1536
+ requestDigest: input.requestDigest,
1537
+ policyVersion: reservation.policy_version,
1538
+ details: {
1539
+ ...input.details,
1540
+ policyEpoch: reservation.policy_epoch,
1541
+ },
1542
+ });
1543
+ return true;
1544
+ });
1545
+ return commit();
1546
+ }
1547
+ getContextGraphAutomaticApprovalUsage(contextGraphId, timestamp) {
1548
+ const since = timestamp - 60 * 60 * 1000;
1549
+ const contextGraphApprovalsLastHour = Number(this.stmt('getContextGraphAutomaticApprovalUsage', `
1550
+ SELECT COUNT(*) AS count
1551
+ FROM context_graph_join_approval_ledger
1552
+ WHERE context_graph_id = ?
1553
+ AND reserved_at >= ?
1554
+ `).get(contextGraphId, since)?.count ?? 0);
1555
+ const nodeApprovalsLastHour = Number(this.stmt('getNodeAutomaticApprovalUsage', `
1556
+ SELECT COUNT(*) AS count
1557
+ FROM context_graph_join_approval_ledger
1558
+ WHERE reserved_at >= ?
1559
+ `).get(since)?.count ?? 0);
1560
+ return { contextGraphApprovalsLastHour, nodeApprovalsLastHour };
1561
+ }
1562
+ listContextGraphJoinPolicyAudit(contextGraphId) {
1563
+ return this.stmt('listContextGraphJoinPolicyAudit', `
1564
+ SELECT id, ts, context_graph_id, event_type, actor, agent_address,
1565
+ outcome, reason, request_digest, policy_version, details
1566
+ FROM context_graph_join_policy_audit
1567
+ WHERE context_graph_id = ?
1568
+ ORDER BY id ASC
1569
+ `).all(contextGraphId);
1570
+ }
1571
+ contextGraphJoinPolicyKey(contextGraphId) {
1572
+ return `contextGraphJoinPolicy:${contextGraphId}`;
1573
+ }
1574
+ upsertVmReconcileNegative(record) {
1575
+ this.stmt('upsertVmReconcileNegative', `
1576
+ INSERT INTO vm_reconcile_negative_cache (
1577
+ cache_key, context_graph_id, failures, next_retry_at, swm_gen,
1578
+ candidate_namespaces, peer_topology_key, updated_at
1579
+ ) VALUES (
1580
+ @cache_key, @context_graph_id, @failures, @next_retry_at, @swm_gen,
1581
+ @candidate_namespaces, @peer_topology_key, @updated_at
1582
+ )
1583
+ ON CONFLICT(cache_key) DO UPDATE SET
1584
+ context_graph_id = excluded.context_graph_id,
1585
+ failures = excluded.failures,
1586
+ next_retry_at = excluded.next_retry_at,
1587
+ swm_gen = excluded.swm_gen,
1588
+ candidate_namespaces = excluded.candidate_namespaces,
1589
+ peer_topology_key = excluded.peer_topology_key,
1590
+ updated_at = excluded.updated_at
1591
+ `).run(record);
1592
+ }
1593
+ getVmReconcileNegative(cacheKey) {
1594
+ return this.stmt('getVmReconcileNegative', 'SELECT * FROM vm_reconcile_negative_cache WHERE cache_key = ?').get(cacheKey);
1595
+ }
1596
+ deleteVmReconcileNegative(cacheKey) {
1597
+ this.stmt('deleteVmReconcileNegative', 'DELETE FROM vm_reconcile_negative_cache WHERE cache_key = ?').run(cacheKey);
1598
+ }
1599
+ deleteVmReconcileNegativesForContextGraph(contextGraphId) {
1600
+ this.stmt('deleteVmReconcileNegativesForContextGraph', 'DELETE FROM vm_reconcile_negative_cache WHERE context_graph_id = ?').run(contextGraphId);
983
1601
  }
984
1602
  // --- Phase F: chain-driven VM reconciliation telemetry ---
985
1603
  /**