@triflux/remote 10.43.0 → 10.44.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.
package/hub/store.mjs CHANGED
@@ -1,13 +1,21 @@
1
- // hub/store.mjs — SQLite 감사 로그/메타데이터 저장소
2
- // 실시간 배달 큐는 router/pipe가 담당하고, SQLite는 재생/감사 용도로만 유지한다.
1
+ // hub/store.mjs — SQLite 감사 로그/메타데이터/역할 레지스트리 저장소
2
+ // 실시간 배달은 router/pipe가 담당하고, 역할 권한과 복구 상태는 SQLite가 보존한다.
3
3
 
4
4
  import { mkdirSync, readFileSync } from "node:fs";
5
5
  import { createRequire } from "node:module";
6
6
  import { dirname, join } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
- import { clampDurationMs, DEFAULT_ASSIGN_TIMEOUT_MS, DEFAULT_ASSIGN_TTL_MS } from "@triflux/core/hub/lib/timeout-defaults.mjs";
8
+ import {
9
+ clampDurationMs,
10
+ DEFAULT_ASSIGN_TIMEOUT_MS,
11
+ DEFAULT_ASSIGN_TTL_MS,
12
+ } from "@triflux/core/hub/lib/timeout-defaults.mjs";
9
13
  import { uuidv7 } from "@triflux/core/hub/lib/uuidv7.mjs";
10
14
  import { recalcConfidence } from "@triflux/core/hub/reflexion.mjs";
15
+ import {
16
+ decodeRoleKey,
17
+ ROLE_REACHABILITY_STATES,
18
+ } from "@triflux/core/hub/role-contract.mjs";
11
19
 
12
20
  export { uuidv7 };
13
21
 
@@ -72,6 +80,21 @@ function parseReflexionRow(row) {
72
80
  };
73
81
  }
74
82
 
83
+ function parseRoleCandidateRow(row) {
84
+ if (!row) return null;
85
+ const { transport_locators_json, ...rest } = row;
86
+ return {
87
+ ...rest,
88
+ transport_locators: parseJson(transport_locators_json, []),
89
+ };
90
+ }
91
+
92
+ function hasTable(db, tableName) {
93
+ return !!db
94
+ .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?")
95
+ .get(tableName);
96
+ }
97
+
75
98
  function hasColumn(db, tableName, columnName) {
76
99
  const rows = db.prepare(`PRAGMA table_info(${tableName})`).all();
77
100
  return rows.some((row) => row.name === columnName);
@@ -82,6 +105,23 @@ function ensureColumn(db, tableName, columnName, definition) {
82
105
  db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`);
83
106
  }
84
107
 
108
+ function ensureV6Columns(db) {
109
+ if (hasTable(db, "agents")) {
110
+ ensureColumn(db, "agents", "project_id", "TEXT");
111
+ ensureColumn(db, "agents", "session_id", "TEXT");
112
+ ensureColumn(db, "agents", "host_id", "TEXT");
113
+ ensureColumn(
114
+ db,
115
+ "agents",
116
+ "transport_locators_json",
117
+ "TEXT NOT NULL DEFAULT '[]'",
118
+ );
119
+ }
120
+ if (hasTable(db, "messages")) {
121
+ ensureColumn(db, "messages", "role_key_wire", "TEXT");
122
+ }
123
+ }
124
+
85
125
  /**
86
126
  * 저장소 생성
87
127
  * @param {string} dbPath
@@ -112,7 +152,7 @@ export function createStore(dbPath, options = {}) {
112
152
  db.exec(
113
153
  "CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT)",
114
154
  );
115
- const SCHEMA_VERSION = "5";
155
+ const SCHEMA_VERSION = "6";
116
156
  const curVer = (() => {
117
157
  try {
118
158
  return db
@@ -126,6 +166,9 @@ export function createStore(dbPath, options = {}) {
126
166
  // 마이그레이션 전략: 스키마 버전이 다르면 schema.sql을 재실행한다.
127
167
  // schema.sql은 CREATE TABLE IF NOT EXISTS 패턴을 사용하므로 멱등하게 적용된다.
128
168
  // 비파괴적 컬럼 추가는 자동으로 처리되지만, 컬럼 제거/이름 변경은 수동 마이그레이션이 필요하다.
169
+ // v5 테이블에는 schema.sql의 v6 인덱스가 참조하는 컬럼이 없으므로
170
+ // CREATE IF NOT EXISTS 재실행 전에 멱등 컬럼 seam을 먼저 적용한다.
171
+ ensureV6Columns(db);
129
172
  if (curVer !== SCHEMA_VERSION) {
130
173
  if (curVer != null) {
131
174
  // 이미 버전이 기록된 DB에서 버전 불일치가 발생한 경우 경고한다.
@@ -138,6 +181,8 @@ export function createStore(dbPath, options = {}) {
138
181
  "INSERT OR REPLACE INTO _meta (key, value) VALUES ('schema_version', ?)",
139
182
  ).run(SCHEMA_VERSION);
140
183
  }
184
+ // 매 부팅 실행해 부분 적용된 마이그레이션도 안전하게 복구한다.
185
+ ensureV6Columns(db);
141
186
  ensureColumn(
142
187
  db,
143
188
  "reflexion_entries",
@@ -185,9 +230,164 @@ export function createStore(dbPath, options = {}) {
185
230
  "UPDATE agents SET status='offline' WHERE status='stale' AND lease_expires_ms < ? - 300000",
186
231
  ),
187
232
 
233
+ getRole: db.prepare(
234
+ "SELECT * FROM role_registry WHERE role_key_wire = ?",
235
+ ),
236
+ insertRoleReservation: db.prepare(`
237
+ INSERT INTO role_registry (
238
+ role_key_wire, project_id, role_kind, scope_id, state,
239
+ holder_agent_id, previous_holder_agent_id, epoch, activation_seq,
240
+ activation_id, activation_deadline_ms, holder_lease_expires_ms,
241
+ charter_version, charter_acked_epoch, retry_count, next_probe_ms,
242
+ blocked_reason, last_transition_ms, last_reason, version
243
+ ) VALUES (
244
+ @role_key_wire, @project_id, @role_kind, @scope_id, @state,
245
+ @holder_agent_id, @previous_holder_agent_id, @epoch, @activation_seq,
246
+ @activation_id, @activation_deadline_ms, @holder_lease_expires_ms,
247
+ @charter_version, @charter_acked_epoch, @retry_count, @next_probe_ms,
248
+ @blocked_reason, @last_transition_ms, @last_reason, @version
249
+ )`),
250
+ updateRoleReservation: db.prepare(`
251
+ UPDATE role_registry SET
252
+ state=@state,
253
+ holder_agent_id=@holder_agent_id,
254
+ previous_holder_agent_id=@previous_holder_agent_id,
255
+ epoch=@epoch,
256
+ activation_seq=@activation_seq,
257
+ activation_id=@activation_id,
258
+ activation_deadline_ms=@activation_deadline_ms,
259
+ holder_lease_expires_ms=@holder_lease_expires_ms,
260
+ charter_version=@charter_version,
261
+ charter_acked_epoch=@charter_acked_epoch,
262
+ retry_count=@retry_count,
263
+ next_probe_ms=@next_probe_ms,
264
+ blocked_reason=@blocked_reason,
265
+ last_transition_ms=@last_transition_ms,
266
+ last_reason=@last_reason,
267
+ version=version + 1
268
+ WHERE role_key_wire=@role_key_wire
269
+ AND version=@expected_version
270
+ AND epoch=@expected_epoch`),
271
+ compareAndSetRole: db.prepare(`
272
+ UPDATE role_registry SET
273
+ state=@state,
274
+ holder_agent_id=@holder_agent_id,
275
+ previous_holder_agent_id=@previous_holder_agent_id,
276
+ activation_id=@activation_id,
277
+ activation_deadline_ms=@activation_deadline_ms,
278
+ holder_lease_expires_ms=@holder_lease_expires_ms,
279
+ charter_version=@charter_version,
280
+ charter_acked_epoch=@charter_acked_epoch,
281
+ retry_count=@retry_count,
282
+ next_probe_ms=@next_probe_ms,
283
+ blocked_reason=@blocked_reason,
284
+ last_transition_ms=@last_transition_ms,
285
+ last_reason=@last_reason,
286
+ version=version + 1
287
+ WHERE role_key_wire=@role_key_wire
288
+ AND epoch=@expected_epoch
289
+ AND activation_seq=@expected_activation_seq
290
+ AND activation_id IS @expected_activation_id
291
+ AND version=@expected_version`),
292
+ listRoles: db.prepare(
293
+ "SELECT * FROM role_registry ORDER BY role_key_wire",
294
+ ),
295
+ expiredHolderRoles: db.prepare(`
296
+ SELECT * FROM role_registry
297
+ WHERE holder_agent_id IS NOT NULL
298
+ AND (holder_lease_expires_ms IS NULL OR holder_lease_expires_ms <= ?)
299
+ ORDER BY role_key_wire`),
300
+ listRoleCandidates: db.prepare(`
301
+ SELECT * FROM role_candidates
302
+ WHERE role_key_wire = ?
303
+ ORDER BY priority DESC, updated_at_ms DESC, agent_id`),
304
+ getRoleCandidate: db.prepare(`
305
+ SELECT * FROM role_candidates
306
+ WHERE role_key_wire = ? AND agent_id = ?`),
307
+ upsertRoleCandidate: db.prepare(`
308
+ INSERT INTO role_candidates (
309
+ role_key_wire, agent_id, source, priority, transport_locators_json,
310
+ consecutive_probe_failures, excluded_until_ms, last_probe_ms,
311
+ last_probe_code, updated_at_ms
312
+ ) VALUES (
313
+ @role_key_wire, @agent_id, @source, @priority,
314
+ @transport_locators_json, @consecutive_probe_failures,
315
+ @excluded_until_ms, @last_probe_ms, @last_probe_code, @updated_at_ms
316
+ )
317
+ ON CONFLICT(role_key_wire, agent_id) DO UPDATE SET
318
+ source=excluded.source,
319
+ priority=excluded.priority,
320
+ transport_locators_json=excluded.transport_locators_json,
321
+ consecutive_probe_failures=excluded.consecutive_probe_failures,
322
+ excluded_until_ms=excluded.excluded_until_ms,
323
+ last_probe_ms=excluded.last_probe_ms,
324
+ last_probe_code=excluded.last_probe_code,
325
+ updated_at_ms=excluded.updated_at_ms`),
326
+ updateCandidateReachability: db.prepare(`
327
+ UPDATE role_candidates SET
328
+ consecutive_probe_failures=@consecutive_probe_failures,
329
+ excluded_until_ms=@excluded_until_ms,
330
+ last_probe_ms=@last_probe_ms,
331
+ last_probe_code=@last_probe_code,
332
+ updated_at_ms=@updated_at_ms
333
+ WHERE role_key_wire=@role_key_wire AND agent_id=@agent_id`),
334
+ pendingRoleMessages: db.prepare(`
335
+ SELECT * FROM messages
336
+ WHERE role_key_wire=?
337
+ AND status IN ('queued','delivered')
338
+ AND expires_at_ms > ?
339
+ ORDER BY priority DESC, created_at_ms ASC, id`),
340
+ recoverExpiredRole: db.prepare(`
341
+ UPDATE role_registry SET
342
+ state='electable',
343
+ previous_holder_agent_id=holder_agent_id,
344
+ holder_agent_id=NULL,
345
+ epoch=epoch + 1,
346
+ activation_seq=activation_seq + 1,
347
+ activation_id=NULL,
348
+ activation_deadline_ms=NULL,
349
+ holder_lease_expires_ms=NULL,
350
+ charter_acked_epoch=NULL,
351
+ blocked_reason=NULL,
352
+ last_transition_ms=@now,
353
+ last_reason='restart_holder_lease_expired',
354
+ version=version + 1
355
+ WHERE role_key_wire=@role_key_wire
356
+ AND version=@expected_version
357
+ AND epoch=@expected_epoch`),
358
+ recoverLiveRole: db.prepare(`
359
+ UPDATE role_registry SET
360
+ state='elected-probing',
361
+ epoch=epoch + 1,
362
+ activation_seq=activation_seq + 1,
363
+ activation_id=@activation_id,
364
+ activation_deadline_ms=NULL,
365
+ charter_acked_epoch=NULL,
366
+ blocked_reason=NULL,
367
+ last_transition_ms=@now,
368
+ last_reason='restart_reprobe_required',
369
+ version=version + 1
370
+ WHERE role_key_wire=@role_key_wire
371
+ AND version=@expected_version
372
+ AND epoch=@expected_epoch`),
373
+ recoverOrphanedActivation: db.prepare(`
374
+ UPDATE role_registry SET
375
+ state='electable',
376
+ epoch=epoch + 1,
377
+ activation_seq=activation_seq + 1,
378
+ activation_id=NULL,
379
+ activation_deadline_ms=NULL,
380
+ charter_acked_epoch=NULL,
381
+ last_transition_ms=@now,
382
+ last_reason='restart_activation_fenced',
383
+ version=version + 1
384
+ WHERE role_key_wire=@role_key_wire
385
+ AND version=@expected_version
386
+ AND epoch=@expected_epoch`),
387
+
188
388
  insertAuditMessage: db.prepare(`
189
- INSERT INTO messages (id, type, from_agent, to_agent, topic, priority, ttl_ms, created_at_ms, expires_at_ms, correlation_id, trace_id, payload_json, status)
190
- VALUES (@id, @type, @from_agent, @to_agent, @topic, @priority, @ttl_ms, @created_at_ms, @expires_at_ms, @correlation_id, @trace_id, @payload_json, @status)`),
389
+ INSERT INTO messages (id, type, from_agent, to_agent, topic, priority, ttl_ms, created_at_ms, expires_at_ms, correlation_id, trace_id, payload_json, status, role_key_wire)
390
+ VALUES (@id, @type, @from_agent, @to_agent, @topic, @priority, @ttl_ms, @created_at_ms, @expires_at_ms, @correlation_id, @trace_id, @payload_json, @status, @role_key_wire)`),
191
391
  getMsg: db.prepare("SELECT * FROM messages WHERE id=?"),
192
392
  getResponse: db.prepare(
193
393
  "SELECT * FROM messages WHERE correlation_id=? AND type='response' ORDER BY created_at_ms DESC LIMIT 1",
@@ -359,6 +559,160 @@ export function createStore(dbPath, options = {}) {
359
559
  return Math.max(1, Math.min(Math.trunc(num), 9));
360
560
  }
361
561
 
562
+ function roleIdentity(input) {
563
+ const roleKeyWire = input?.role_key_wire ?? input?.roleKeyWire;
564
+ const decoded = decodeRoleKey(roleKeyWire);
565
+ const identity = {
566
+ role_key_wire: roleKeyWire,
567
+ project_id: decoded.project_id,
568
+ role_kind: decoded.role_kind,
569
+ scope_id: decoded.scope_id ?? "",
570
+ };
571
+ for (const field of ["project_id", "role_kind", "scope_id"]) {
572
+ if (Object.hasOwn(input, field) && input[field] !== identity[field]) {
573
+ throw new Error(`role key identity mismatch: ${field}`);
574
+ }
575
+ }
576
+ return identity;
577
+ }
578
+
579
+ function roleFailureReason(current, expected) {
580
+ if (!current) return "not_found";
581
+ if (current.version !== expected.version) return "stale_version";
582
+ if (current.epoch !== expected.epoch) return "stale_epoch";
583
+ if (current.activation_seq !== expected.activation_seq) {
584
+ return "stale_activation_seq";
585
+ }
586
+ if (current.activation_id !== expected.activation_id) {
587
+ return "stale_activation_id";
588
+ }
589
+ return "cas_conflict";
590
+ }
591
+
592
+ const reserveRole = db.transaction((input) => {
593
+ const identity = roleIdentity(input);
594
+ const current = S.getRole.get(identity.role_key_wire) ?? null;
595
+ const expectedVersion =
596
+ input.expected_version ?? input.expectedVersion ?? current?.version;
597
+ const expectedEpoch =
598
+ input.expected_epoch ?? input.expectedEpoch ?? current?.epoch;
599
+
600
+ if (current && expectedVersion !== current.version) {
601
+ return { ok: false, reason: "stale_version", role: current };
602
+ }
603
+ if (current && expectedEpoch !== current.epoch) {
604
+ return { ok: false, reason: "stale_epoch", role: current };
605
+ }
606
+
607
+ const nextEpoch =
608
+ input.epoch ?? input.next_epoch ?? (current ? current.epoch + 1 : 0);
609
+ if (current && nextEpoch !== current.epoch + 1) {
610
+ return { ok: false, reason: "stale_epoch", role: current };
611
+ }
612
+ const nextActivationSeq =
613
+ input.activation_seq ??
614
+ input.next_activation_seq ??
615
+ (current ? current.activation_seq + 1 : 0);
616
+ if (current && nextActivationSeq !== current.activation_seq + 1) {
617
+ return { ok: false, reason: "stale_activation_seq", role: current };
618
+ }
619
+
620
+ const state =
621
+ input.state ?? (input.holder_agent_id ? "elected-probing" : "electable");
622
+ if (!ROLE_REACHABILITY_STATES.includes(state)) {
623
+ throw new Error(`invalid role reachability state: ${state}`);
624
+ }
625
+ const row = {
626
+ ...identity,
627
+ state,
628
+ holder_agent_id: input.holder_agent_id ?? null,
629
+ previous_holder_agent_id:
630
+ input.previous_holder_agent_id ??
631
+ (current?.holder_agent_id !== input.holder_agent_id
632
+ ? current?.holder_agent_id ?? null
633
+ : current?.previous_holder_agent_id ?? null),
634
+ epoch: nextEpoch,
635
+ activation_seq: nextActivationSeq,
636
+ activation_id:
637
+ input.activation_id ??
638
+ (input.holder_agent_id ? uuidv7() : current?.activation_id ?? null),
639
+ activation_deadline_ms:
640
+ input.activation_deadline_ms ?? current?.activation_deadline_ms ?? null,
641
+ holder_lease_expires_ms:
642
+ input.holder_lease_expires_ms ??
643
+ current?.holder_lease_expires_ms ??
644
+ null,
645
+ charter_version: input.charter_version ?? current?.charter_version ?? null,
646
+ charter_acked_epoch:
647
+ input.charter_acked_epoch ?? current?.charter_acked_epoch ?? null,
648
+ retry_count: input.retry_count ?? current?.retry_count ?? 0,
649
+ next_probe_ms: input.next_probe_ms ?? current?.next_probe_ms ?? null,
650
+ blocked_reason:
651
+ input.blocked_reason === undefined
652
+ ? current?.blocked_reason ?? null
653
+ : input.blocked_reason,
654
+ last_transition_ms: input.last_transition_ms ?? Date.now(),
655
+ last_reason: input.last_reason ?? "role_reserved",
656
+ version: 0,
657
+ expected_version: current?.version,
658
+ expected_epoch: current?.epoch,
659
+ };
660
+
661
+ if (!current) {
662
+ S.insertRoleReservation.run(row);
663
+ } else if (S.updateRoleReservation.run(row).changes !== 1) {
664
+ const latest = S.getRole.get(identity.role_key_wire) ?? null;
665
+ return {
666
+ ok: false,
667
+ reason: roleFailureReason(latest, {
668
+ version: current.version,
669
+ epoch: current.epoch,
670
+ activation_seq: current.activation_seq,
671
+ activation_id: current.activation_id,
672
+ }),
673
+ role: latest,
674
+ };
675
+ }
676
+ return { ok: true, role: S.getRole.get(identity.role_key_wire) };
677
+ });
678
+
679
+ const recoverRegistry = db.transaction((now) => {
680
+ for (const role of S.listRoles.all()) {
681
+ const guard = {
682
+ role_key_wire: role.role_key_wire,
683
+ expected_version: role.version,
684
+ expected_epoch: role.epoch,
685
+ now,
686
+ };
687
+ if (
688
+ role.holder_agent_id &&
689
+ (role.holder_lease_expires_ms == null ||
690
+ role.holder_lease_expires_ms <= now)
691
+ ) {
692
+ S.recoverExpiredRole.run(guard);
693
+ } else if (role.holder_agent_id) {
694
+ S.recoverLiveRole.run({ ...guard, activation_id: uuidv7() });
695
+ } else if (role.activation_id || role.state === "elected-probing") {
696
+ S.recoverOrphanedActivation.run(guard);
697
+ }
698
+ }
699
+
700
+ const roles = S.listRoles.all();
701
+ return {
702
+ roles,
703
+ candidates: roles.flatMap((role) =>
704
+ S.listRoleCandidates
705
+ .all(role.role_key_wire)
706
+ .map(parseRoleCandidateRow),
707
+ ),
708
+ pending_messages: roles.flatMap((role) =>
709
+ S.pendingRoleMessages
710
+ .all(role.role_key_wire, now)
711
+ .map(parseMessageRow),
712
+ ),
713
+ };
714
+ });
715
+
362
716
  const store = {
363
717
  db,
364
718
  uuidv7,
@@ -421,7 +775,11 @@ export function createStore(dbPath, options = {}) {
421
775
  agent_id: agentId,
422
776
  lease_expires_ms: now + ttlMs,
423
777
  server_time_ms: now,
424
- effective: { updated: write.changes > 0, store_type: "sqlite", db_path: dbPath ?? null },
778
+ effective: {
779
+ updated: write.changes > 0,
780
+ store_type: "sqlite",
781
+ db_path: dbPath ?? null,
782
+ },
425
783
  };
426
784
  },
427
785
 
@@ -456,6 +814,233 @@ export function createStore(dbPath, options = {}) {
456
814
  return S.setAgentStatus.run(status, agentId).changes > 0;
457
815
  },
458
816
 
817
+ getRole(roleKeyWire) {
818
+ decodeRoleKey(roleKeyWire);
819
+ return S.getRole.get(roleKeyWire) ?? null;
820
+ },
821
+
822
+ upsertRoleReservation(input) {
823
+ return reserveRole.immediate(input);
824
+ },
825
+
826
+ compareAndSetRole(input) {
827
+ const identity = roleIdentity(input);
828
+ const current = S.getRole.get(identity.role_key_wire) ?? null;
829
+ if (!current) return { ok: false, reason: "not_found", role: null };
830
+
831
+ const hasExpectedActivationSeq =
832
+ Object.hasOwn(input, "expected_activation_seq") ||
833
+ Object.hasOwn(input, "expectedActivationSeq") ||
834
+ Object.hasOwn(input, "activation_seq");
835
+ const hasExpectedActivationId =
836
+ Object.hasOwn(input, "expected_activation_id") ||
837
+ Object.hasOwn(input, "expectedActivationId") ||
838
+ Object.hasOwn(input, "activation_id");
839
+ const expected = {
840
+ version: input.expected_version ?? input.expectedVersion ?? input.version,
841
+ epoch: input.expected_epoch ?? input.expectedEpoch ?? input.epoch,
842
+ activation_seq:
843
+ input.expected_activation_seq ??
844
+ input.expectedActivationSeq ??
845
+ input.activation_seq,
846
+ activation_id: Object.hasOwn(input, "expected_activation_id")
847
+ ? input.expected_activation_id
848
+ : Object.hasOwn(input, "expectedActivationId")
849
+ ? input.expectedActivationId
850
+ : input.activation_id,
851
+ };
852
+ if (
853
+ expected.version == null ||
854
+ expected.epoch == null ||
855
+ !hasExpectedActivationSeq ||
856
+ !hasExpectedActivationId
857
+ ) {
858
+ return { ok: false, reason: "invalid_cas", role: current };
859
+ }
860
+
861
+ const reason = roleFailureReason(current, expected);
862
+ if (reason !== "cas_conflict") {
863
+ return { ok: false, reason, role: current };
864
+ }
865
+
866
+ const patch = input.patch ?? input.changes ?? {};
867
+ const state = patch.state ?? current.state;
868
+ if (!ROLE_REACHABILITY_STATES.includes(state)) {
869
+ throw new Error(`invalid role reachability state: ${state}`);
870
+ }
871
+ const next = {
872
+ role_key_wire: identity.role_key_wire,
873
+ expected_version: expected.version,
874
+ expected_epoch: expected.epoch,
875
+ expected_activation_seq: expected.activation_seq,
876
+ expected_activation_id: expected.activation_id,
877
+ state,
878
+ holder_agent_id:
879
+ patch.holder_agent_id === undefined
880
+ ? current.holder_agent_id
881
+ : patch.holder_agent_id,
882
+ previous_holder_agent_id:
883
+ patch.previous_holder_agent_id === undefined
884
+ ? current.previous_holder_agent_id
885
+ : patch.previous_holder_agent_id,
886
+ activation_id:
887
+ patch.activation_id === undefined
888
+ ? current.activation_id
889
+ : patch.activation_id,
890
+ activation_deadline_ms:
891
+ patch.activation_deadline_ms === undefined
892
+ ? current.activation_deadline_ms
893
+ : patch.activation_deadline_ms,
894
+ holder_lease_expires_ms:
895
+ patch.holder_lease_expires_ms === undefined
896
+ ? current.holder_lease_expires_ms
897
+ : patch.holder_lease_expires_ms,
898
+ charter_version:
899
+ patch.charter_version === undefined
900
+ ? current.charter_version
901
+ : patch.charter_version,
902
+ charter_acked_epoch:
903
+ patch.charter_acked_epoch === undefined
904
+ ? current.charter_acked_epoch
905
+ : patch.charter_acked_epoch,
906
+ retry_count: patch.retry_count ?? current.retry_count,
907
+ next_probe_ms:
908
+ patch.next_probe_ms === undefined
909
+ ? current.next_probe_ms
910
+ : patch.next_probe_ms,
911
+ blocked_reason:
912
+ patch.blocked_reason === undefined
913
+ ? current.blocked_reason
914
+ : patch.blocked_reason,
915
+ last_transition_ms: patch.last_transition_ms ?? Date.now(),
916
+ last_reason: patch.last_reason ?? current.last_reason,
917
+ };
918
+ if (S.compareAndSetRole.run(next).changes !== 1) {
919
+ const latest = S.getRole.get(identity.role_key_wire) ?? null;
920
+ return {
921
+ ok: false,
922
+ reason: roleFailureReason(latest, expected),
923
+ role: latest,
924
+ };
925
+ }
926
+ return { ok: true, role: S.getRole.get(identity.role_key_wire) };
927
+ },
928
+
929
+ listRoleKeys(filter = {}) {
930
+ const clauses = [];
931
+ const params = {};
932
+ for (const field of ["project_id", "role_kind", "scope_id", "state"]) {
933
+ if (filter[field] !== undefined) {
934
+ clauses.push(`${field}=@${field}`);
935
+ params[field] = filter[field];
936
+ }
937
+ }
938
+ if (Array.isArray(filter.states) && filter.states.length > 0) {
939
+ clauses.push(
940
+ `state IN (${filter.states.map((_, index) => `@state_${index}`).join(",")})`,
941
+ );
942
+ filter.states.forEach((state, index) => {
943
+ params[`state_${index}`] = state;
944
+ });
945
+ }
946
+ const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
947
+ return db
948
+ .prepare(
949
+ `SELECT role_key_wire FROM role_registry${where} ORDER BY role_key_wire`,
950
+ )
951
+ .pluck()
952
+ .all(params);
953
+ },
954
+
955
+ listRolesWithExpiredHolder(now) {
956
+ return S.expiredHolderRoles.all(now);
957
+ },
958
+
959
+ listRoleCandidates(roleKeyWire) {
960
+ decodeRoleKey(roleKeyWire);
961
+ return S.listRoleCandidates.all(roleKeyWire).map(parseRoleCandidateRow);
962
+ },
963
+
964
+ upsertRoleCandidate(input) {
965
+ const { role_key_wire } = roleIdentity(input);
966
+ const current = S.getRoleCandidate.get(role_key_wire, input.agent_id);
967
+ S.upsertRoleCandidate.run({
968
+ role_key_wire,
969
+ agent_id: input.agent_id,
970
+ source: input.source ?? current?.source ?? "operator",
971
+ priority: input.priority ?? current?.priority ?? 0,
972
+ transport_locators_json: JSON.stringify(
973
+ input.transport_locators ??
974
+ parseJson(current?.transport_locators_json, []),
975
+ ),
976
+ consecutive_probe_failures:
977
+ input.consecutive_probe_failures ??
978
+ current?.consecutive_probe_failures ??
979
+ 0,
980
+ excluded_until_ms:
981
+ input.excluded_until_ms === undefined
982
+ ? current?.excluded_until_ms ?? null
983
+ : input.excluded_until_ms,
984
+ last_probe_ms:
985
+ input.last_probe_ms === undefined
986
+ ? current?.last_probe_ms ?? null
987
+ : input.last_probe_ms,
988
+ last_probe_code:
989
+ input.last_probe_code === undefined
990
+ ? current?.last_probe_code ?? null
991
+ : input.last_probe_code,
992
+ updated_at_ms: input.updated_at_ms ?? Date.now(),
993
+ });
994
+ return parseRoleCandidateRow(
995
+ S.getRoleCandidate.get(role_key_wire, input.agent_id),
996
+ );
997
+ },
998
+
999
+ updateCandidateReachability(input) {
1000
+ const { role_key_wire } = roleIdentity(input);
1001
+ const current = S.getRoleCandidate.get(role_key_wire, input.agent_id);
1002
+ if (!current) return { ok: false, reason: "not_found" };
1003
+
1004
+ let failures =
1005
+ input.consecutive_probe_failures ??
1006
+ current.consecutive_probe_failures;
1007
+ if (input.probe_failed === true || input.increment_probe_failures === true) {
1008
+ failures = current.consecutive_probe_failures + 1;
1009
+ } else if (input.probe_succeeded === true) {
1010
+ failures = 0;
1011
+ }
1012
+ S.updateCandidateReachability.run({
1013
+ role_key_wire,
1014
+ agent_id: input.agent_id,
1015
+ consecutive_probe_failures: failures,
1016
+ excluded_until_ms:
1017
+ input.excluded_until_ms === undefined
1018
+ ? current.excluded_until_ms
1019
+ : input.excluded_until_ms,
1020
+ last_probe_ms: input.last_probe_ms ?? Date.now(),
1021
+ last_probe_code:
1022
+ input.last_probe_code === undefined
1023
+ ? current.last_probe_code
1024
+ : input.last_probe_code,
1025
+ updated_at_ms: input.updated_at_ms ?? Date.now(),
1026
+ });
1027
+ return {
1028
+ ok: true,
1029
+ candidate: parseRoleCandidateRow(
1030
+ S.getRoleCandidate.get(role_key_wire, input.agent_id),
1031
+ ),
1032
+ };
1033
+ },
1034
+
1035
+ listPendingRoleMessages(roleKeyWire, now) {
1036
+ decodeRoleKey(roleKeyWire);
1037
+ return S.pendingRoleMessages.all(roleKeyWire, now).map(parseMessageRow);
1038
+ },
1039
+
1040
+ recoverRoleRegistry(now) {
1041
+ return recoverRegistry.immediate(now);
1042
+ },
1043
+
459
1044
  auditLog({
460
1045
  type,
461
1046
  from,
@@ -467,6 +1052,7 @@ export function createStore(dbPath, options = {}) {
467
1052
  trace_id,
468
1053
  correlation_id,
469
1054
  status = "queued",
1055
+ role_key_wire = null,
470
1056
  }) {
471
1057
  const now = Date.now();
472
1058
  const row = {
@@ -483,6 +1069,7 @@ export function createStore(dbPath, options = {}) {
483
1069
  trace_id: trace_id || uuidv7(),
484
1070
  payload_json: JSON.stringify(payload),
485
1071
  status,
1072
+ role_key_wire,
486
1073
  };
487
1074
  S.insertAuditMessage.run(row);
488
1075
  return { ...row, payload };
@@ -638,7 +1225,10 @@ export function createStore(dbPath, options = {}) {
638
1225
  error = null,
639
1226
  }) {
640
1227
  const now = Date.now();
641
- const normalizedTimeout = clampDurationMs(timeout_ms, DEFAULT_ASSIGN_TIMEOUT_MS);
1228
+ const normalizedTimeout = clampDurationMs(
1229
+ timeout_ms,
1230
+ DEFAULT_ASSIGN_TIMEOUT_MS,
1231
+ );
642
1232
  const row = {
643
1233
  job_id: job_id || uuidv7(),
644
1234
  supervisor_agent,
@@ -761,7 +1351,9 @@ export function createStore(dbPath, options = {}) {
761
1351
  : nextStatus === "running"
762
1352
  ? current.started_at_ms || now
763
1353
  : current.started_at_ms,
764
- dispatched_at_ms: Object.hasOwn(patch, "dispatched_at_ms") ? patch.dispatched_at_ms : current.dispatched_at_ms,
1354
+ dispatched_at_ms: Object.hasOwn(patch, "dispatched_at_ms")
1355
+ ? patch.dispatched_at_ms
1356
+ : current.dispatched_at_ms,
765
1357
  completed_at_ms: Object.hasOwn(patch, "completed_at_ms")
766
1358
  ? patch.completed_at_ms
767
1359
  : isTerminal