triflux 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.
Files changed (47) hide show
  1. package/README.md +2 -0
  2. package/adapters/codex/skills/tfx-harness/SKILL.md +30 -0
  3. package/adapters/codex/skills/tfx-harness/skill.json +5 -0
  4. package/bin/triflux.mjs +14 -0
  5. package/hooks/keyword-rules.json +139 -19
  6. package/hub/cli-adapter-base.mjs +11 -2
  7. package/hub/codex-adapter.mjs +3 -0
  8. package/hub/intent.mjs +24 -32
  9. package/hub/router.mjs +272 -0
  10. package/hub/schema.sql +61 -2
  11. package/hub/server.mjs +42 -0
  12. package/hub/store.mjs +577 -5
  13. package/hub/team/conductor.mjs +23 -2
  14. package/hub/team/execution-mode.mjs +12 -2
  15. package/hub/team/headless.mjs +16 -0
  16. package/hub/team/retry-state-machine.mjs +8 -3
  17. package/hub/team/swarm-hypervisor.mjs +1 -0
  18. package/hub/team/swarm-preflight.mjs +47 -0
  19. package/hub/team/synapse-http.mjs +149 -4
  20. package/hub/team/worktree-lifecycle.mjs +56 -59
  21. package/hub/tools.mjs +11 -0
  22. package/hub/workers/codex-mcp.mjs +20 -4
  23. package/hub/workers/delegator-mcp.mjs +3 -49
  24. package/package.json +2 -1
  25. package/scripts/__tests__/lint-skills.test.mjs +68 -0
  26. package/scripts/__tests__/tfx-route-node-entry.test.mjs +36 -0
  27. package/scripts/keyword-detector.mjs +55 -8
  28. package/scripts/lib/agent-route-policy.mjs +360 -0
  29. package/scripts/lib/cli-codex.mjs +22 -160
  30. package/scripts/lib/codex-profile-config.mjs +29 -3
  31. package/scripts/lib/keyword-rules.mjs +12 -0
  32. package/scripts/lint-skills.mjs +65 -0
  33. package/scripts/pack.mjs +13 -0
  34. package/scripts/release/check-packages-mirror.mjs +5 -0
  35. package/scripts/setup.mjs +94 -2
  36. package/scripts/tfx-route.mjs +14 -1
  37. package/scripts/tfx-route.sh +233 -70
  38. package/skills/tfx-harness/SKILL.md +19 -76
  39. package/skills/tfx-hub/SKILL.md +1 -1
  40. package/skills/tfx-interview/SKILL.md +3 -1
  41. package/skills/tfx-interview/skill.json +1 -13
  42. package/skills/tfx-profile/SKILL.md +25 -15
  43. package/skills/tfx-prune/SKILL.md +3 -1
  44. package/skills/tfx-prune/skill.json +1 -8
  45. package/skills/tfx-setup/SKILL.md +1 -1
  46. package/tui/codex-profile.mjs +11 -2
  47. package/tui/setup.mjs +2 -0
package/hub/store.mjs CHANGED
@@ -1,5 +1,5 @@
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";
@@ -12,6 +12,7 @@ import {
12
12
  } from "./lib/timeout-defaults.mjs";
13
13
  import { uuidv7 } from "./lib/uuidv7.mjs";
14
14
  import { recalcConfidence } from "./reflexion.mjs";
15
+ import { decodeRoleKey, ROLE_REACHABILITY_STATES } from "./role-contract.mjs";
15
16
 
16
17
  export { uuidv7 };
17
18
 
@@ -76,6 +77,21 @@ function parseReflexionRow(row) {
76
77
  };
77
78
  }
78
79
 
80
+ function parseRoleCandidateRow(row) {
81
+ if (!row) return null;
82
+ const { transport_locators_json, ...rest } = row;
83
+ return {
84
+ ...rest,
85
+ transport_locators: parseJson(transport_locators_json, []),
86
+ };
87
+ }
88
+
89
+ function hasTable(db, tableName) {
90
+ return !!db
91
+ .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?")
92
+ .get(tableName);
93
+ }
94
+
79
95
  function hasColumn(db, tableName, columnName) {
80
96
  const rows = db.prepare(`PRAGMA table_info(${tableName})`).all();
81
97
  return rows.some((row) => row.name === columnName);
@@ -86,6 +102,23 @@ function ensureColumn(db, tableName, columnName, definition) {
86
102
  db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`);
87
103
  }
88
104
 
105
+ function ensureV6Columns(db) {
106
+ if (hasTable(db, "agents")) {
107
+ ensureColumn(db, "agents", "project_id", "TEXT");
108
+ ensureColumn(db, "agents", "session_id", "TEXT");
109
+ ensureColumn(db, "agents", "host_id", "TEXT");
110
+ ensureColumn(
111
+ db,
112
+ "agents",
113
+ "transport_locators_json",
114
+ "TEXT NOT NULL DEFAULT '[]'",
115
+ );
116
+ }
117
+ if (hasTable(db, "messages")) {
118
+ ensureColumn(db, "messages", "role_key_wire", "TEXT");
119
+ }
120
+ }
121
+
89
122
  /**
90
123
  * 저장소 생성
91
124
  * @param {string} dbPath
@@ -116,7 +149,7 @@ export function createStore(dbPath, options = {}) {
116
149
  db.exec(
117
150
  "CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT)",
118
151
  );
119
- const SCHEMA_VERSION = "5";
152
+ const SCHEMA_VERSION = "6";
120
153
  const curVer = (() => {
121
154
  try {
122
155
  return db
@@ -130,6 +163,9 @@ export function createStore(dbPath, options = {}) {
130
163
  // 마이그레이션 전략: 스키마 버전이 다르면 schema.sql을 재실행한다.
131
164
  // schema.sql은 CREATE TABLE IF NOT EXISTS 패턴을 사용하므로 멱등하게 적용된다.
132
165
  // 비파괴적 컬럼 추가는 자동으로 처리되지만, 컬럼 제거/이름 변경은 수동 마이그레이션이 필요하다.
166
+ // v5 테이블에는 schema.sql의 v6 인덱스가 참조하는 컬럼이 없으므로
167
+ // CREATE IF NOT EXISTS 재실행 전에 멱등 컬럼 seam을 먼저 적용한다.
168
+ ensureV6Columns(db);
133
169
  if (curVer !== SCHEMA_VERSION) {
134
170
  if (curVer != null) {
135
171
  // 이미 버전이 기록된 DB에서 버전 불일치가 발생한 경우 경고한다.
@@ -142,6 +178,8 @@ export function createStore(dbPath, options = {}) {
142
178
  "INSERT OR REPLACE INTO _meta (key, value) VALUES ('schema_version', ?)",
143
179
  ).run(SCHEMA_VERSION);
144
180
  }
181
+ // 매 부팅 실행해 부분 적용된 마이그레이션도 안전하게 복구한다.
182
+ ensureV6Columns(db);
145
183
  ensureColumn(
146
184
  db,
147
185
  "reflexion_entries",
@@ -189,9 +227,160 @@ export function createStore(dbPath, options = {}) {
189
227
  "UPDATE agents SET status='offline' WHERE status='stale' AND lease_expires_ms < ? - 300000",
190
228
  ),
191
229
 
230
+ getRole: db.prepare("SELECT * FROM role_registry WHERE role_key_wire = ?"),
231
+ insertRoleReservation: db.prepare(`
232
+ INSERT INTO role_registry (
233
+ role_key_wire, project_id, role_kind, scope_id, state,
234
+ holder_agent_id, previous_holder_agent_id, epoch, activation_seq,
235
+ activation_id, activation_deadline_ms, holder_lease_expires_ms,
236
+ charter_version, charter_acked_epoch, retry_count, next_probe_ms,
237
+ blocked_reason, last_transition_ms, last_reason, version
238
+ ) VALUES (
239
+ @role_key_wire, @project_id, @role_kind, @scope_id, @state,
240
+ @holder_agent_id, @previous_holder_agent_id, @epoch, @activation_seq,
241
+ @activation_id, @activation_deadline_ms, @holder_lease_expires_ms,
242
+ @charter_version, @charter_acked_epoch, @retry_count, @next_probe_ms,
243
+ @blocked_reason, @last_transition_ms, @last_reason, @version
244
+ )`),
245
+ updateRoleReservation: db.prepare(`
246
+ UPDATE role_registry SET
247
+ state=@state,
248
+ holder_agent_id=@holder_agent_id,
249
+ previous_holder_agent_id=@previous_holder_agent_id,
250
+ epoch=@epoch,
251
+ activation_seq=@activation_seq,
252
+ activation_id=@activation_id,
253
+ activation_deadline_ms=@activation_deadline_ms,
254
+ holder_lease_expires_ms=@holder_lease_expires_ms,
255
+ charter_version=@charter_version,
256
+ charter_acked_epoch=@charter_acked_epoch,
257
+ retry_count=@retry_count,
258
+ next_probe_ms=@next_probe_ms,
259
+ blocked_reason=@blocked_reason,
260
+ last_transition_ms=@last_transition_ms,
261
+ last_reason=@last_reason,
262
+ version=version + 1
263
+ WHERE role_key_wire=@role_key_wire
264
+ AND version=@expected_version
265
+ AND epoch=@expected_epoch`),
266
+ compareAndSetRole: db.prepare(`
267
+ UPDATE role_registry SET
268
+ state=@state,
269
+ holder_agent_id=@holder_agent_id,
270
+ previous_holder_agent_id=@previous_holder_agent_id,
271
+ activation_id=@activation_id,
272
+ activation_deadline_ms=@activation_deadline_ms,
273
+ holder_lease_expires_ms=@holder_lease_expires_ms,
274
+ charter_version=@charter_version,
275
+ charter_acked_epoch=@charter_acked_epoch,
276
+ retry_count=@retry_count,
277
+ next_probe_ms=@next_probe_ms,
278
+ blocked_reason=@blocked_reason,
279
+ last_transition_ms=@last_transition_ms,
280
+ last_reason=@last_reason,
281
+ version=version + 1
282
+ WHERE role_key_wire=@role_key_wire
283
+ AND epoch=@expected_epoch
284
+ AND activation_seq=@expected_activation_seq
285
+ AND activation_id IS @expected_activation_id
286
+ AND version=@expected_version`),
287
+ listRoles: db.prepare("SELECT * FROM role_registry ORDER BY role_key_wire"),
288
+ expiredHolderRoles: db.prepare(`
289
+ SELECT * FROM role_registry
290
+ WHERE holder_agent_id IS NOT NULL
291
+ AND (holder_lease_expires_ms IS NULL OR holder_lease_expires_ms <= ?)
292
+ ORDER BY role_key_wire`),
293
+ listRoleCandidates: db.prepare(`
294
+ SELECT * FROM role_candidates
295
+ WHERE role_key_wire = ?
296
+ ORDER BY priority DESC, updated_at_ms DESC, agent_id`),
297
+ getRoleCandidate: db.prepare(`
298
+ SELECT * FROM role_candidates
299
+ WHERE role_key_wire = ? AND agent_id = ?`),
300
+ upsertRoleCandidate: db.prepare(`
301
+ INSERT INTO role_candidates (
302
+ role_key_wire, agent_id, source, priority, transport_locators_json,
303
+ consecutive_probe_failures, excluded_until_ms, last_probe_ms,
304
+ last_probe_code, updated_at_ms
305
+ ) VALUES (
306
+ @role_key_wire, @agent_id, @source, @priority,
307
+ @transport_locators_json, @consecutive_probe_failures,
308
+ @excluded_until_ms, @last_probe_ms, @last_probe_code, @updated_at_ms
309
+ )
310
+ ON CONFLICT(role_key_wire, agent_id) DO UPDATE SET
311
+ source=excluded.source,
312
+ priority=excluded.priority,
313
+ transport_locators_json=excluded.transport_locators_json,
314
+ consecutive_probe_failures=excluded.consecutive_probe_failures,
315
+ excluded_until_ms=excluded.excluded_until_ms,
316
+ last_probe_ms=excluded.last_probe_ms,
317
+ last_probe_code=excluded.last_probe_code,
318
+ updated_at_ms=excluded.updated_at_ms`),
319
+ updateCandidateReachability: db.prepare(`
320
+ UPDATE role_candidates SET
321
+ consecutive_probe_failures=@consecutive_probe_failures,
322
+ excluded_until_ms=@excluded_until_ms,
323
+ last_probe_ms=@last_probe_ms,
324
+ last_probe_code=@last_probe_code,
325
+ updated_at_ms=@updated_at_ms
326
+ WHERE role_key_wire=@role_key_wire AND agent_id=@agent_id`),
327
+ pendingRoleMessages: db.prepare(`
328
+ SELECT * FROM messages
329
+ WHERE role_key_wire=?
330
+ AND status IN ('queued','delivered')
331
+ AND expires_at_ms > ?
332
+ ORDER BY priority DESC, created_at_ms ASC, id`),
333
+ recoverExpiredRole: db.prepare(`
334
+ UPDATE role_registry SET
335
+ state='electable',
336
+ previous_holder_agent_id=holder_agent_id,
337
+ holder_agent_id=NULL,
338
+ epoch=epoch + 1,
339
+ activation_seq=activation_seq + 1,
340
+ activation_id=NULL,
341
+ activation_deadline_ms=NULL,
342
+ holder_lease_expires_ms=NULL,
343
+ charter_acked_epoch=NULL,
344
+ blocked_reason=NULL,
345
+ last_transition_ms=@now,
346
+ last_reason='restart_holder_lease_expired',
347
+ version=version + 1
348
+ WHERE role_key_wire=@role_key_wire
349
+ AND version=@expected_version
350
+ AND epoch=@expected_epoch`),
351
+ recoverLiveRole: db.prepare(`
352
+ UPDATE role_registry SET
353
+ state='elected-probing',
354
+ epoch=epoch + 1,
355
+ activation_seq=activation_seq + 1,
356
+ activation_id=@activation_id,
357
+ activation_deadline_ms=NULL,
358
+ charter_acked_epoch=NULL,
359
+ blocked_reason=NULL,
360
+ last_transition_ms=@now,
361
+ last_reason='restart_reprobe_required',
362
+ version=version + 1
363
+ WHERE role_key_wire=@role_key_wire
364
+ AND version=@expected_version
365
+ AND epoch=@expected_epoch`),
366
+ recoverOrphanedActivation: db.prepare(`
367
+ UPDATE role_registry SET
368
+ state='electable',
369
+ epoch=epoch + 1,
370
+ activation_seq=activation_seq + 1,
371
+ activation_id=NULL,
372
+ activation_deadline_ms=NULL,
373
+ charter_acked_epoch=NULL,
374
+ last_transition_ms=@now,
375
+ last_reason='restart_activation_fenced',
376
+ version=version + 1
377
+ WHERE role_key_wire=@role_key_wire
378
+ AND version=@expected_version
379
+ AND epoch=@expected_epoch`),
380
+
192
381
  insertAuditMessage: db.prepare(`
193
- 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)
194
- VALUES (@id, @type, @from_agent, @to_agent, @topic, @priority, @ttl_ms, @created_at_ms, @expires_at_ms, @correlation_id, @trace_id, @payload_json, @status)`),
382
+ 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)
383
+ 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)`),
195
384
  getMsg: db.prepare("SELECT * FROM messages WHERE id=?"),
196
385
  getResponse: db.prepare(
197
386
  "SELECT * FROM messages WHERE correlation_id=? AND type='response' ORDER BY created_at_ms DESC LIMIT 1",
@@ -363,6 +552,157 @@ export function createStore(dbPath, options = {}) {
363
552
  return Math.max(1, Math.min(Math.trunc(num), 9));
364
553
  }
365
554
 
555
+ function roleIdentity(input) {
556
+ const roleKeyWire = input?.role_key_wire ?? input?.roleKeyWire;
557
+ const decoded = decodeRoleKey(roleKeyWire);
558
+ const identity = {
559
+ role_key_wire: roleKeyWire,
560
+ project_id: decoded.project_id,
561
+ role_kind: decoded.role_kind,
562
+ scope_id: decoded.scope_id ?? "",
563
+ };
564
+ for (const field of ["project_id", "role_kind", "scope_id"]) {
565
+ if (Object.hasOwn(input, field) && input[field] !== identity[field]) {
566
+ throw new Error(`role key identity mismatch: ${field}`);
567
+ }
568
+ }
569
+ return identity;
570
+ }
571
+
572
+ function roleFailureReason(current, expected) {
573
+ if (!current) return "not_found";
574
+ if (current.version !== expected.version) return "stale_version";
575
+ if (current.epoch !== expected.epoch) return "stale_epoch";
576
+ if (current.activation_seq !== expected.activation_seq) {
577
+ return "stale_activation_seq";
578
+ }
579
+ if (current.activation_id !== expected.activation_id) {
580
+ return "stale_activation_id";
581
+ }
582
+ return "cas_conflict";
583
+ }
584
+
585
+ const reserveRole = db.transaction((input) => {
586
+ const identity = roleIdentity(input);
587
+ const current = S.getRole.get(identity.role_key_wire) ?? null;
588
+ const expectedVersion =
589
+ input.expected_version ?? input.expectedVersion ?? current?.version;
590
+ const expectedEpoch =
591
+ input.expected_epoch ?? input.expectedEpoch ?? current?.epoch;
592
+
593
+ if (current && expectedVersion !== current.version) {
594
+ return { ok: false, reason: "stale_version", role: current };
595
+ }
596
+ if (current && expectedEpoch !== current.epoch) {
597
+ return { ok: false, reason: "stale_epoch", role: current };
598
+ }
599
+
600
+ const nextEpoch =
601
+ input.epoch ?? input.next_epoch ?? (current ? current.epoch + 1 : 0);
602
+ if (current && nextEpoch !== current.epoch + 1) {
603
+ return { ok: false, reason: "stale_epoch", role: current };
604
+ }
605
+ const nextActivationSeq =
606
+ input.activation_seq ??
607
+ input.next_activation_seq ??
608
+ (current ? current.activation_seq + 1 : 0);
609
+ if (current && nextActivationSeq !== current.activation_seq + 1) {
610
+ return { ok: false, reason: "stale_activation_seq", role: current };
611
+ }
612
+
613
+ const state =
614
+ input.state ?? (input.holder_agent_id ? "elected-probing" : "electable");
615
+ if (!ROLE_REACHABILITY_STATES.includes(state)) {
616
+ throw new Error(`invalid role reachability state: ${state}`);
617
+ }
618
+ const row = {
619
+ ...identity,
620
+ state,
621
+ holder_agent_id: input.holder_agent_id ?? null,
622
+ previous_holder_agent_id:
623
+ input.previous_holder_agent_id ??
624
+ (current?.holder_agent_id !== input.holder_agent_id
625
+ ? (current?.holder_agent_id ?? null)
626
+ : (current?.previous_holder_agent_id ?? null)),
627
+ epoch: nextEpoch,
628
+ activation_seq: nextActivationSeq,
629
+ activation_id:
630
+ input.activation_id ??
631
+ (input.holder_agent_id ? uuidv7() : (current?.activation_id ?? null)),
632
+ activation_deadline_ms:
633
+ input.activation_deadline_ms ?? current?.activation_deadline_ms ?? null,
634
+ holder_lease_expires_ms:
635
+ input.holder_lease_expires_ms ??
636
+ current?.holder_lease_expires_ms ??
637
+ null,
638
+ charter_version:
639
+ input.charter_version ?? current?.charter_version ?? null,
640
+ charter_acked_epoch:
641
+ input.charter_acked_epoch ?? current?.charter_acked_epoch ?? null,
642
+ retry_count: input.retry_count ?? current?.retry_count ?? 0,
643
+ next_probe_ms: input.next_probe_ms ?? current?.next_probe_ms ?? null,
644
+ blocked_reason:
645
+ input.blocked_reason === undefined
646
+ ? (current?.blocked_reason ?? null)
647
+ : input.blocked_reason,
648
+ last_transition_ms: input.last_transition_ms ?? Date.now(),
649
+ last_reason: input.last_reason ?? "role_reserved",
650
+ version: 0,
651
+ expected_version: current?.version,
652
+ expected_epoch: current?.epoch,
653
+ };
654
+
655
+ if (!current) {
656
+ S.insertRoleReservation.run(row);
657
+ } else if (S.updateRoleReservation.run(row).changes !== 1) {
658
+ const latest = S.getRole.get(identity.role_key_wire) ?? null;
659
+ return {
660
+ ok: false,
661
+ reason: roleFailureReason(latest, {
662
+ version: current.version,
663
+ epoch: current.epoch,
664
+ activation_seq: current.activation_seq,
665
+ activation_id: current.activation_id,
666
+ }),
667
+ role: latest,
668
+ };
669
+ }
670
+ return { ok: true, role: S.getRole.get(identity.role_key_wire) };
671
+ });
672
+
673
+ const recoverRegistry = db.transaction((now) => {
674
+ for (const role of S.listRoles.all()) {
675
+ const guard = {
676
+ role_key_wire: role.role_key_wire,
677
+ expected_version: role.version,
678
+ expected_epoch: role.epoch,
679
+ now,
680
+ };
681
+ if (
682
+ role.holder_agent_id &&
683
+ (role.holder_lease_expires_ms == null ||
684
+ role.holder_lease_expires_ms <= now)
685
+ ) {
686
+ S.recoverExpiredRole.run(guard);
687
+ } else if (role.holder_agent_id) {
688
+ S.recoverLiveRole.run({ ...guard, activation_id: uuidv7() });
689
+ } else if (role.activation_id || role.state === "elected-probing") {
690
+ S.recoverOrphanedActivation.run(guard);
691
+ }
692
+ }
693
+
694
+ const roles = S.listRoles.all();
695
+ return {
696
+ roles,
697
+ candidates: roles.flatMap((role) =>
698
+ S.listRoleCandidates.all(role.role_key_wire).map(parseRoleCandidateRow),
699
+ ),
700
+ pending_messages: roles.flatMap((role) =>
701
+ S.pendingRoleMessages.all(role.role_key_wire, now).map(parseMessageRow),
702
+ ),
703
+ };
704
+ });
705
+
366
706
  const store = {
367
707
  db,
368
708
  uuidv7,
@@ -464,6 +804,236 @@ export function createStore(dbPath, options = {}) {
464
804
  return S.setAgentStatus.run(status, agentId).changes > 0;
465
805
  },
466
806
 
807
+ getRole(roleKeyWire) {
808
+ decodeRoleKey(roleKeyWire);
809
+ return S.getRole.get(roleKeyWire) ?? null;
810
+ },
811
+
812
+ upsertRoleReservation(input) {
813
+ return reserveRole.immediate(input);
814
+ },
815
+
816
+ compareAndSetRole(input) {
817
+ const identity = roleIdentity(input);
818
+ const current = S.getRole.get(identity.role_key_wire) ?? null;
819
+ if (!current) return { ok: false, reason: "not_found", role: null };
820
+
821
+ const hasExpectedActivationSeq =
822
+ Object.hasOwn(input, "expected_activation_seq") ||
823
+ Object.hasOwn(input, "expectedActivationSeq") ||
824
+ Object.hasOwn(input, "activation_seq");
825
+ const hasExpectedActivationId =
826
+ Object.hasOwn(input, "expected_activation_id") ||
827
+ Object.hasOwn(input, "expectedActivationId") ||
828
+ Object.hasOwn(input, "activation_id");
829
+ const expected = {
830
+ version:
831
+ input.expected_version ?? input.expectedVersion ?? input.version,
832
+ epoch: input.expected_epoch ?? input.expectedEpoch ?? input.epoch,
833
+ activation_seq:
834
+ input.expected_activation_seq ??
835
+ input.expectedActivationSeq ??
836
+ input.activation_seq,
837
+ activation_id: Object.hasOwn(input, "expected_activation_id")
838
+ ? input.expected_activation_id
839
+ : Object.hasOwn(input, "expectedActivationId")
840
+ ? input.expectedActivationId
841
+ : input.activation_id,
842
+ };
843
+ if (
844
+ expected.version == null ||
845
+ expected.epoch == null ||
846
+ !hasExpectedActivationSeq ||
847
+ !hasExpectedActivationId
848
+ ) {
849
+ return { ok: false, reason: "invalid_cas", role: current };
850
+ }
851
+
852
+ const reason = roleFailureReason(current, expected);
853
+ if (reason !== "cas_conflict") {
854
+ return { ok: false, reason, role: current };
855
+ }
856
+
857
+ const patch = input.patch ?? input.changes ?? {};
858
+ const state = patch.state ?? current.state;
859
+ if (!ROLE_REACHABILITY_STATES.includes(state)) {
860
+ throw new Error(`invalid role reachability state: ${state}`);
861
+ }
862
+ const next = {
863
+ role_key_wire: identity.role_key_wire,
864
+ expected_version: expected.version,
865
+ expected_epoch: expected.epoch,
866
+ expected_activation_seq: expected.activation_seq,
867
+ expected_activation_id: expected.activation_id,
868
+ state,
869
+ holder_agent_id:
870
+ patch.holder_agent_id === undefined
871
+ ? current.holder_agent_id
872
+ : patch.holder_agent_id,
873
+ previous_holder_agent_id:
874
+ patch.previous_holder_agent_id === undefined
875
+ ? current.previous_holder_agent_id
876
+ : patch.previous_holder_agent_id,
877
+ activation_id:
878
+ patch.activation_id === undefined
879
+ ? current.activation_id
880
+ : patch.activation_id,
881
+ activation_deadline_ms:
882
+ patch.activation_deadline_ms === undefined
883
+ ? current.activation_deadline_ms
884
+ : patch.activation_deadline_ms,
885
+ holder_lease_expires_ms:
886
+ patch.holder_lease_expires_ms === undefined
887
+ ? current.holder_lease_expires_ms
888
+ : patch.holder_lease_expires_ms,
889
+ charter_version:
890
+ patch.charter_version === undefined
891
+ ? current.charter_version
892
+ : patch.charter_version,
893
+ charter_acked_epoch:
894
+ patch.charter_acked_epoch === undefined
895
+ ? current.charter_acked_epoch
896
+ : patch.charter_acked_epoch,
897
+ retry_count: patch.retry_count ?? current.retry_count,
898
+ next_probe_ms:
899
+ patch.next_probe_ms === undefined
900
+ ? current.next_probe_ms
901
+ : patch.next_probe_ms,
902
+ blocked_reason:
903
+ patch.blocked_reason === undefined
904
+ ? current.blocked_reason
905
+ : patch.blocked_reason,
906
+ last_transition_ms: patch.last_transition_ms ?? Date.now(),
907
+ last_reason: patch.last_reason ?? current.last_reason,
908
+ };
909
+ if (S.compareAndSetRole.run(next).changes !== 1) {
910
+ const latest = S.getRole.get(identity.role_key_wire) ?? null;
911
+ return {
912
+ ok: false,
913
+ reason: roleFailureReason(latest, expected),
914
+ role: latest,
915
+ };
916
+ }
917
+ return { ok: true, role: S.getRole.get(identity.role_key_wire) };
918
+ },
919
+
920
+ listRoleKeys(filter = {}) {
921
+ const clauses = [];
922
+ const params = {};
923
+ for (const field of ["project_id", "role_kind", "scope_id", "state"]) {
924
+ if (filter[field] !== undefined) {
925
+ clauses.push(`${field}=@${field}`);
926
+ params[field] = filter[field];
927
+ }
928
+ }
929
+ if (Array.isArray(filter.states) && filter.states.length > 0) {
930
+ clauses.push(
931
+ `state IN (${filter.states.map((_, index) => `@state_${index}`).join(",")})`,
932
+ );
933
+ filter.states.forEach((state, index) => {
934
+ params[`state_${index}`] = state;
935
+ });
936
+ }
937
+ const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
938
+ return db
939
+ .prepare(
940
+ `SELECT role_key_wire FROM role_registry${where} ORDER BY role_key_wire`,
941
+ )
942
+ .pluck()
943
+ .all(params);
944
+ },
945
+
946
+ listRolesWithExpiredHolder(now) {
947
+ return S.expiredHolderRoles.all(now);
948
+ },
949
+
950
+ listRoleCandidates(roleKeyWire) {
951
+ decodeRoleKey(roleKeyWire);
952
+ return S.listRoleCandidates.all(roleKeyWire).map(parseRoleCandidateRow);
953
+ },
954
+
955
+ upsertRoleCandidate(input) {
956
+ const { role_key_wire } = roleIdentity(input);
957
+ const current = S.getRoleCandidate.get(role_key_wire, input.agent_id);
958
+ S.upsertRoleCandidate.run({
959
+ role_key_wire,
960
+ agent_id: input.agent_id,
961
+ source: input.source ?? current?.source ?? "operator",
962
+ priority: input.priority ?? current?.priority ?? 0,
963
+ transport_locators_json: JSON.stringify(
964
+ input.transport_locators ??
965
+ parseJson(current?.transport_locators_json, []),
966
+ ),
967
+ consecutive_probe_failures:
968
+ input.consecutive_probe_failures ??
969
+ current?.consecutive_probe_failures ??
970
+ 0,
971
+ excluded_until_ms:
972
+ input.excluded_until_ms === undefined
973
+ ? (current?.excluded_until_ms ?? null)
974
+ : input.excluded_until_ms,
975
+ last_probe_ms:
976
+ input.last_probe_ms === undefined
977
+ ? (current?.last_probe_ms ?? null)
978
+ : input.last_probe_ms,
979
+ last_probe_code:
980
+ input.last_probe_code === undefined
981
+ ? (current?.last_probe_code ?? null)
982
+ : input.last_probe_code,
983
+ updated_at_ms: input.updated_at_ms ?? Date.now(),
984
+ });
985
+ return parseRoleCandidateRow(
986
+ S.getRoleCandidate.get(role_key_wire, input.agent_id),
987
+ );
988
+ },
989
+
990
+ updateCandidateReachability(input) {
991
+ const { role_key_wire } = roleIdentity(input);
992
+ const current = S.getRoleCandidate.get(role_key_wire, input.agent_id);
993
+ if (!current) return { ok: false, reason: "not_found" };
994
+
995
+ let failures =
996
+ input.consecutive_probe_failures ?? current.consecutive_probe_failures;
997
+ if (
998
+ input.probe_failed === true ||
999
+ input.increment_probe_failures === true
1000
+ ) {
1001
+ failures = current.consecutive_probe_failures + 1;
1002
+ } else if (input.probe_succeeded === true) {
1003
+ failures = 0;
1004
+ }
1005
+ S.updateCandidateReachability.run({
1006
+ role_key_wire,
1007
+ agent_id: input.agent_id,
1008
+ consecutive_probe_failures: failures,
1009
+ excluded_until_ms:
1010
+ input.excluded_until_ms === undefined
1011
+ ? current.excluded_until_ms
1012
+ : input.excluded_until_ms,
1013
+ last_probe_ms: input.last_probe_ms ?? Date.now(),
1014
+ last_probe_code:
1015
+ input.last_probe_code === undefined
1016
+ ? current.last_probe_code
1017
+ : input.last_probe_code,
1018
+ updated_at_ms: input.updated_at_ms ?? Date.now(),
1019
+ });
1020
+ return {
1021
+ ok: true,
1022
+ candidate: parseRoleCandidateRow(
1023
+ S.getRoleCandidate.get(role_key_wire, input.agent_id),
1024
+ ),
1025
+ };
1026
+ },
1027
+
1028
+ listPendingRoleMessages(roleKeyWire, now) {
1029
+ decodeRoleKey(roleKeyWire);
1030
+ return S.pendingRoleMessages.all(roleKeyWire, now).map(parseMessageRow);
1031
+ },
1032
+
1033
+ recoverRoleRegistry(now) {
1034
+ return recoverRegistry.immediate(now);
1035
+ },
1036
+
467
1037
  auditLog({
468
1038
  type,
469
1039
  from,
@@ -475,6 +1045,7 @@ export function createStore(dbPath, options = {}) {
475
1045
  trace_id,
476
1046
  correlation_id,
477
1047
  status = "queued",
1048
+ role_key_wire = null,
478
1049
  }) {
479
1050
  const now = Date.now();
480
1051
  const row = {
@@ -491,6 +1062,7 @@ export function createStore(dbPath, options = {}) {
491
1062
  trace_id: trace_id || uuidv7(),
492
1063
  payload_json: JSON.stringify(payload),
493
1064
  status,
1065
+ role_key_wire,
494
1066
  };
495
1067
  S.insertAuditMessage.run(row);
496
1068
  return { ...row, payload };