@triflux/remote 10.42.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,12 +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 {
9
+ clampDurationMs,
10
+ DEFAULT_ASSIGN_TIMEOUT_MS,
11
+ DEFAULT_ASSIGN_TTL_MS,
12
+ } from "@triflux/core/hub/lib/timeout-defaults.mjs";
8
13
  import { uuidv7 } from "@triflux/core/hub/lib/uuidv7.mjs";
9
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";
10
19
 
11
20
  export { uuidv7 };
12
21
 
@@ -71,6 +80,21 @@ function parseReflexionRow(row) {
71
80
  };
72
81
  }
73
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
+
74
98
  function hasColumn(db, tableName, columnName) {
75
99
  const rows = db.prepare(`PRAGMA table_info(${tableName})`).all();
76
100
  return rows.some((row) => row.name === columnName);
@@ -81,6 +105,23 @@ function ensureColumn(db, tableName, columnName, definition) {
81
105
  db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`);
82
106
  }
83
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
+
84
125
  /**
85
126
  * 저장소 생성
86
127
  * @param {string} dbPath
@@ -111,7 +152,7 @@ export function createStore(dbPath, options = {}) {
111
152
  db.exec(
112
153
  "CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT)",
113
154
  );
114
- const SCHEMA_VERSION = "4";
155
+ const SCHEMA_VERSION = "6";
115
156
  const curVer = (() => {
116
157
  try {
117
158
  return db
@@ -125,6 +166,9 @@ export function createStore(dbPath, options = {}) {
125
166
  // 마이그레이션 전략: 스키마 버전이 다르면 schema.sql을 재실행한다.
126
167
  // schema.sql은 CREATE TABLE IF NOT EXISTS 패턴을 사용하므로 멱등하게 적용된다.
127
168
  // 비파괴적 컬럼 추가는 자동으로 처리되지만, 컬럼 제거/이름 변경은 수동 마이그레이션이 필요하다.
169
+ // v5 테이블에는 schema.sql의 v6 인덱스가 참조하는 컬럼이 없으므로
170
+ // CREATE IF NOT EXISTS 재실행 전에 멱등 컬럼 seam을 먼저 적용한다.
171
+ ensureV6Columns(db);
128
172
  if (curVer !== SCHEMA_VERSION) {
129
173
  if (curVer != null) {
130
174
  // 이미 버전이 기록된 DB에서 버전 불일치가 발생한 경우 경고한다.
@@ -137,12 +181,15 @@ export function createStore(dbPath, options = {}) {
137
181
  "INSERT OR REPLACE INTO _meta (key, value) VALUES ('schema_version', ?)",
138
182
  ).run(SCHEMA_VERSION);
139
183
  }
184
+ // 매 부팅 실행해 부분 적용된 마이그레이션도 안전하게 복구한다.
185
+ ensureV6Columns(db);
140
186
  ensureColumn(
141
187
  db,
142
188
  "reflexion_entries",
143
189
  "type",
144
190
  "TEXT NOT NULL DEFAULT 'reflexion'",
145
191
  );
192
+ ensureColumn(db, "assign_jobs", "dispatched_at_ms", "INTEGER");
146
193
  ensureColumn(
147
194
  db,
148
195
  "reflexion_entries",
@@ -183,9 +230,164 @@ export function createStore(dbPath, options = {}) {
183
230
  "UPDATE agents SET status='offline' WHERE status='stale' AND lease_expires_ms < ? - 300000",
184
231
  ),
185
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
+
186
388
  insertAuditMessage: db.prepare(`
187
- 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)
188
- 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)`),
189
391
  getMsg: db.prepare("SELECT * FROM messages WHERE id=?"),
190
392
  getResponse: db.prepare(
191
393
  "SELECT * FROM messages WHERE correlation_id=? AND type='response' ORDER BY created_at_ms DESC LIMIT 1",
@@ -233,12 +435,12 @@ export function createStore(dbPath, options = {}) {
233
435
  job_id, supervisor_agent, worker_agent, topic, task, payload_json,
234
436
  status, attempt, retry_count, max_retries, priority, ttl_ms, timeout_ms, deadline_ms,
235
437
  trace_id, correlation_id, last_message_id, result_json, error_json,
236
- created_at_ms, updated_at_ms, started_at_ms, completed_at_ms, last_retry_at_ms
438
+ created_at_ms, updated_at_ms, started_at_ms, dispatched_at_ms, completed_at_ms, last_retry_at_ms
237
439
  ) VALUES (
238
440
  @job_id, @supervisor_agent, @worker_agent, @topic, @task, @payload_json,
239
441
  @status, @attempt, @retry_count, @max_retries, @priority, @ttl_ms, @timeout_ms, @deadline_ms,
240
442
  @trace_id, @correlation_id, @last_message_id, @result_json, @error_json,
241
- @created_at_ms, @updated_at_ms, @started_at_ms, @completed_at_ms, @last_retry_at_ms
443
+ @created_at_ms, @updated_at_ms, @started_at_ms, @dispatched_at_ms, @completed_at_ms, @last_retry_at_ms
242
444
  )`),
243
445
  getAssign: db.prepare("SELECT * FROM assign_jobs WHERE job_id = ?"),
244
446
  updateAssign: db.prepare(`
@@ -263,6 +465,7 @@ export function createStore(dbPath, options = {}) {
263
465
  error_json=@error_json,
264
466
  updated_at_ms=@updated_at_ms,
265
467
  started_at_ms=@started_at_ms,
468
+ dispatched_at_ms=@dispatched_at_ms,
266
469
  completed_at_ms=@completed_at_ms,
267
470
  last_retry_at_ms=@last_retry_at_ms
268
471
  WHERE job_id=@job_id`),
@@ -356,12 +559,160 @@ export function createStore(dbPath, options = {}) {
356
559
  return Math.max(1, Math.min(Math.trunc(num), 9));
357
560
  }
358
561
 
359
- function clampDuration(value, fallback = 600000, min = 1000, max = 86400000) {
360
- const num = Number(value);
361
- if (!Number.isFinite(num)) return fallback;
362
- return Math.max(min, Math.min(Math.trunc(num), max));
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;
363
577
  }
364
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
+
365
716
  const store = {
366
717
  db,
367
718
  uuidv7,
@@ -419,11 +770,16 @@ export function createStore(dbPath, options = {}) {
419
770
 
420
771
  refreshLease(agentId, ttlMs = 30000) {
421
772
  const now = Date.now();
422
- S.heartbeat.run(now, now + ttlMs, agentId);
773
+ const write = S.heartbeat.run(now, now + ttlMs, agentId);
423
774
  return {
424
775
  agent_id: agentId,
425
776
  lease_expires_ms: now + ttlMs,
426
777
  server_time_ms: now,
778
+ effective: {
779
+ updated: write.changes > 0,
780
+ store_type: "sqlite",
781
+ db_path: dbPath ?? null,
782
+ },
427
783
  };
428
784
  },
429
785
 
@@ -458,6 +814,233 @@ export function createStore(dbPath, options = {}) {
458
814
  return S.setAgentStatus.run(status, agentId).changes > 0;
459
815
  },
460
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
+
461
1044
  auditLog({
462
1045
  type,
463
1046
  from,
@@ -469,6 +1052,7 @@ export function createStore(dbPath, options = {}) {
469
1052
  trace_id,
470
1053
  correlation_id,
471
1054
  status = "queued",
1055
+ role_key_wire = null,
472
1056
  }) {
473
1057
  const now = Date.now();
474
1058
  const row = {
@@ -485,6 +1069,7 @@ export function createStore(dbPath, options = {}) {
485
1069
  trace_id: trace_id || uuidv7(),
486
1070
  payload_json: JSON.stringify(payload),
487
1071
  status,
1072
+ role_key_wire,
488
1073
  };
489
1074
  S.insertAuditMessage.run(row);
490
1075
  return { ...row, payload };
@@ -629,9 +1214,10 @@ export function createStore(dbPath, options = {}) {
629
1214
  retry_count = 0,
630
1215
  max_retries = 0,
631
1216
  priority = 5,
632
- ttl_ms = 600000,
633
- timeout_ms = 600000,
1217
+ ttl_ms = DEFAULT_ASSIGN_TTL_MS,
1218
+ timeout_ms = DEFAULT_ASSIGN_TIMEOUT_MS,
634
1219
  deadline_ms,
1220
+ dispatched_at_ms = null,
635
1221
  trace_id,
636
1222
  correlation_id,
637
1223
  last_message_id = null,
@@ -639,7 +1225,10 @@ export function createStore(dbPath, options = {}) {
639
1225
  error = null,
640
1226
  }) {
641
1227
  const now = Date.now();
642
- const normalizedTimeout = clampDuration(timeout_ms, 600000);
1228
+ const normalizedTimeout = clampDurationMs(
1229
+ timeout_ms,
1230
+ DEFAULT_ASSIGN_TIMEOUT_MS,
1231
+ );
643
1232
  const row = {
644
1233
  job_id: job_id || uuidv7(),
645
1234
  supervisor_agent,
@@ -652,7 +1241,7 @@ export function createStore(dbPath, options = {}) {
652
1241
  retry_count: Math.max(0, Number(retry_count) || 0),
653
1242
  max_retries: Math.max(0, Number(max_retries) || 0),
654
1243
  priority: clampPriority(priority, 5),
655
- ttl_ms: clampDuration(ttl_ms, normalizedTimeout),
1244
+ ttl_ms: clampDurationMs(ttl_ms, normalizedTimeout),
656
1245
  timeout_ms: normalizedTimeout,
657
1246
  deadline_ms: Number.isFinite(Number(deadline_ms))
658
1247
  ? Math.trunc(Number(deadline_ms))
@@ -665,6 +1254,10 @@ export function createStore(dbPath, options = {}) {
665
1254
  created_at_ms: now,
666
1255
  updated_at_ms: now,
667
1256
  started_at_ms: status === "running" ? now : null,
1257
+ dispatched_at_ms:
1258
+ dispatched_at_ms == null || !Number.isFinite(Number(dispatched_at_ms))
1259
+ ? null
1260
+ : Math.trunc(Number(dispatched_at_ms)),
668
1261
  completed_at_ms: ["succeeded", "failed", "timed_out"].includes(status)
669
1262
  ? now
670
1263
  : null,
@@ -689,7 +1282,7 @@ export function createStore(dbPath, options = {}) {
689
1282
  const isTerminal = ["succeeded", "failed", "timed_out"].includes(
690
1283
  nextStatus,
691
1284
  );
692
- const nextTimeout = clampDuration(
1285
+ const nextTimeout = clampDurationMs(
693
1286
  patch.timeout_ms ?? current.timeout_ms,
694
1287
  current.timeout_ms,
695
1288
  );
@@ -717,7 +1310,7 @@ export function createStore(dbPath, options = {}) {
717
1310
  patch.priority ?? current.priority,
718
1311
  current.priority || 5,
719
1312
  ),
720
- ttl_ms: clampDuration(
1313
+ ttl_ms: clampDurationMs(
721
1314
  patch.ttl_ms ?? current.ttl_ms,
722
1315
  current.ttl_ms || nextTimeout,
723
1316
  ),
@@ -758,6 +1351,9 @@ export function createStore(dbPath, options = {}) {
758
1351
  : nextStatus === "running"
759
1352
  ? current.started_at_ms || now
760
1353
  : current.started_at_ms,
1354
+ dispatched_at_ms: Object.hasOwn(patch, "dispatched_at_ms")
1355
+ ? patch.dispatched_at_ms
1356
+ : current.dispatched_at_ms,
761
1357
  completed_at_ms: Object.hasOwn(patch, "completed_at_ms")
762
1358
  ? patch.completed_at_ms
763
1359
  : isTerminal
@@ -849,7 +1445,7 @@ export function createStore(dbPath, options = {}) {
849
1445
  current.attempt + 1,
850
1446
  Number(patch.attempt ?? current.attempt + 1) || 1,
851
1447
  );
852
- const nextTimeout = clampDuration(
1448
+ const nextTimeout = clampDurationMs(
853
1449
  patch.timeout_ms ?? current.timeout_ms,
854
1450
  current.timeout_ms,
855
1451
  );
@@ -861,6 +1457,7 @@ export function createStore(dbPath, options = {}) {
861
1457
  deadline_ms: Date.now() + nextTimeout,
862
1458
  completed_at_ms: null,
863
1459
  started_at_ms: null,
1460
+ dispatched_at_ms: null,
864
1461
  last_retry_at_ms: Date.now(),
865
1462
  result: patch.result ?? null,
866
1463
  error: Object.hasOwn(patch, "error") ? patch.error : current.error,