@opengeni/db 0.27.8 → 0.27.11

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 (63) hide show
  1. package/dist/{chunk-M6EOXYHK.js → chunk-JYQPJ6Y5.js} +59 -50
  2. package/dist/chunk-JYQPJ6Y5.js.map +1 -0
  3. package/dist/{chunk-IHPCI4GV.js → chunk-RPHPNVWW.js} +3 -1
  4. package/dist/chunk-RPHPNVWW.js.map +1 -0
  5. package/dist/codex-token-resolver.d.ts +50 -8
  6. package/dist/connection-token-resolver.d.ts +45 -7
  7. package/dist/database.d.ts +137 -0
  8. package/dist/index.d.ts +83 -184
  9. package/dist/index.js +2983 -2691
  10. package/dist/index.js.map +1 -1
  11. package/dist/insights.d.ts +1 -1
  12. package/dist/memory-governance.d.ts +1 -1
  13. package/dist/new-session-drafts.d.ts +1 -1
  14. package/dist/preference-registry.d.ts +1 -1
  15. package/dist/provision-roles.d.ts +1 -1
  16. package/dist/provision-roles.js +1 -1
  17. package/dist/runtime-posture.d.ts +3 -3
  18. package/dist/schema.d.ts +155 -96
  19. package/dist/schema.js +3 -1
  20. package/dist/scoped-knowledge.d.ts +1 -1
  21. package/dist/session-control.d.ts +1 -1
  22. package/dist/session-queue-commands.d.ts +1 -1
  23. package/dist/session-realtime-context.d.ts +1 -1
  24. package/dist/session-realtime-ledger.d.ts +1 -1
  25. package/dist/session-realtime-mirror.d.ts +1 -1
  26. package/dist/session-realtime-state.d.ts +1 -1
  27. package/dist/session-realtime-terminal.d.ts +1 -1
  28. package/dist/session-realtime.d.ts +1 -1
  29. package/dist/session-tool-call-settlement.d.ts +1 -1
  30. package/dist/turn-initiator.d.ts +1 -1
  31. package/dist/workspace-artifacts.d.ts +1 -1
  32. package/dist/workspace-instruction-policies.d.ts +1 -1
  33. package/drizzle/0171_social_connection_subject_ownership.sql +53 -0
  34. package/drizzle/0172_retire_model_visible_github_token.sql +86 -0
  35. package/drizzle/0173_codex_auth_boundaries.sql +107 -0
  36. package/drizzle/0174_session_wake_live_interruption.sql +80 -0
  37. package/package.json +4 -4
  38. package/src/codex-token-resolver.ts +102 -49
  39. package/src/connection-token-resolver.ts +67 -26
  40. package/src/database.ts +393 -0
  41. package/src/index.ts +747 -645
  42. package/src/insights.ts +2 -2
  43. package/src/memory-governance.ts +2 -2
  44. package/src/new-session-drafts.ts +1 -1
  45. package/src/preference-registry.ts +2 -2
  46. package/src/provision-roles.ts +1 -1
  47. package/src/runtime-posture.ts +3 -1
  48. package/src/schema.ts +70 -50
  49. package/src/scoped-knowledge.ts +2 -2
  50. package/src/session-control.ts +1 -1
  51. package/src/session-queue-commands.ts +1 -1
  52. package/src/session-realtime-context.ts +1 -1
  53. package/src/session-realtime-ledger.ts +1 -1
  54. package/src/session-realtime-mirror.ts +1 -1
  55. package/src/session-realtime-state.ts +1 -1
  56. package/src/session-realtime-terminal.ts +1 -1
  57. package/src/session-realtime.ts +1 -1
  58. package/src/session-tool-call-settlement.ts +1 -1
  59. package/src/turn-initiator.ts +1 -1
  60. package/src/workspace-artifacts.ts +2 -2
  61. package/src/workspace-instruction-policies.ts +2 -2
  62. package/dist/chunk-IHPCI4GV.js.map +0 -1
  63. package/dist/chunk-M6EOXYHK.js.map +0 -1
@@ -0,0 +1,53 @@
1
+ -- deployment-mode: rolling
2
+ -- Extend first-party social credentials with the same workspace/personal ownership boundary as MCP connections.
3
+
4
+ ALTER TABLE social_connections
5
+ ADD COLUMN subject_id text;
6
+
7
+ DROP INDEX social_connections_workspace_provider_handle_idx;
8
+ CREATE UNIQUE INDEX social_connections_workspace_provider_handle_idx
9
+ ON social_connections (workspace_id, provider, account_handle)
10
+ WHERE subject_id IS NULL;
11
+ CREATE UNIQUE INDEX social_connections_subject_provider_handle_idx
12
+ ON social_connections (workspace_id, subject_id, provider)
13
+ WHERE subject_id IS NOT NULL;
14
+ CREATE INDEX social_connections_subject_provider_status_idx
15
+ ON social_connections (workspace_id, subject_id, provider, status);
16
+
17
+ DROP POLICY workspace_isolation ON social_connections;
18
+ CREATE POLICY workspace_isolation ON social_connections
19
+ USING (
20
+ opengeni_private.workspace_rls_visible(account_id, workspace_id)
21
+ AND (
22
+ subject_id IS NULL
23
+ OR subject_id = nullif(current_setting('opengeni.subject_id', true), '')
24
+ )
25
+ )
26
+ WITH CHECK (
27
+ opengeni_private.workspace_rls_visible(account_id, workspace_id)
28
+ AND (
29
+ subject_id IS NULL
30
+ OR subject_id = nullif(current_setting('opengeni.subject_id', true), '')
31
+ )
32
+ );
33
+
34
+ DROP POLICY workspace_isolation ON social_posts;
35
+ CREATE POLICY workspace_isolation ON social_posts
36
+ USING (
37
+ opengeni_private.workspace_rls_visible(account_id, workspace_id)
38
+ AND EXISTS (
39
+ SELECT 1
40
+ FROM social_connections connection
41
+ WHERE connection.id = social_posts.connection_id
42
+ AND connection.workspace_id = social_posts.workspace_id
43
+ )
44
+ )
45
+ WITH CHECK (
46
+ opengeni_private.workspace_rls_visible(account_id, workspace_id)
47
+ AND EXISTS (
48
+ SELECT 1
49
+ FROM social_connections connection
50
+ WHERE connection.id = social_posts.connection_id
51
+ AND connection.workspace_id = social_posts.workspace_id
52
+ )
53
+ );
@@ -0,0 +1,86 @@
1
+ -- deployment-mode: maintenance
2
+ -- GitHub App installation credentials are host-owned run material. Retire the
3
+ -- model-visible github_token MCP tool from every durable session selection and
4
+ -- prevent an old writer from reintroducing it after the coordinated cutover.
5
+
6
+ UPDATE "sessions"
7
+ SET "first_party_mcp_tools" = "first_party_mcp_tools" - 'github_token'
8
+ WHERE "first_party_mcp_tools" ? 'github_token';
9
+
10
+ ALTER TABLE "sessions"
11
+ ALTER COLUMN "first_party_mcp_tools"
12
+ SET DEFAULT '[
13
+ "set_session_title",
14
+ "goal_set",
15
+ "goal_update",
16
+ "goal_complete",
17
+ "goal_pause",
18
+ "memory_search",
19
+ "memory_save",
20
+ "memory_correct",
21
+ "preference_registry_summary",
22
+ "preference_registry_get",
23
+ "sandboxes_list",
24
+ "sandbox_attach",
25
+ "sandbox_swap",
26
+ "run_on",
27
+ "sandbox_provision",
28
+ "rig_list",
29
+ "rig_get",
30
+ "rig_propose_change",
31
+ "rig_verify",
32
+ "rig_promote",
33
+ "sessions_list",
34
+ "session_get",
35
+ "session_events",
36
+ "session_create",
37
+ "session_send_message",
38
+ "session_pause",
39
+ "session_resume",
40
+ "session_steer",
41
+ "set_other_session_title",
42
+ "variable_set_list",
43
+ "environment_list",
44
+ "variable_set_set_variable",
45
+ "environment_set_variable",
46
+ "github_connect_link",
47
+ "github_repositories_list",
48
+ "social_connections_list",
49
+ "social_posts_recent",
50
+ "social_daily_analysis_context",
51
+ "social_search_live",
52
+ "social_mentions_live",
53
+ "social_thread_fetch",
54
+ "social_posts_sync",
55
+ "social_post_reply",
56
+ "scheduled_tasks_list",
57
+ "scheduled_tasks_get",
58
+ "scheduled_tasks_create",
59
+ "scheduled_tasks_update",
60
+ "scheduled_tasks_pause",
61
+ "scheduled_tasks_resume",
62
+ "scheduled_tasks_trigger",
63
+ "scheduled_tasks_delete",
64
+ "scheduled_task_runs_list",
65
+ "slack_bot_list_channels",
66
+ "slack_bot_channel_history",
67
+ "slack_bot_thread_replies",
68
+ "slack_bot_list_users",
69
+ "slack_bot_list_files",
70
+ "slack_bot_file_info",
71
+ "slack_bot_file_content",
72
+ "slack_bot_post_message",
73
+ "slack_bot_delete_message",
74
+ "artifacts_list",
75
+ "artifacts_get_source",
76
+ "artifacts_create",
77
+ "artifacts_publish",
78
+ "artifacts_rollback"
79
+ ]'::jsonb;
80
+
81
+ ALTER TABLE "sessions"
82
+ DROP CONSTRAINT IF EXISTS "sessions_first_party_mcp_tools_no_model_credentials_chk";
83
+
84
+ ALTER TABLE "sessions"
85
+ ADD CONSTRAINT "sessions_first_party_mcp_tools_no_model_credentials_chk"
86
+ CHECK (NOT ("first_party_mcp_tools" @> '["github_token"]'::jsonb));
@@ -0,0 +1,107 @@
1
+ -- deployment-mode: maintenance
2
+
3
+ SET LOCAL lock_timeout = '5s';
4
+ SET LOCAL statement_timeout = '10min';
5
+
6
+ -- Reject mixed-version writers before locking, then repeat after the locks to
7
+ -- close the connect-before-lock race. The deployment must keep API and workers
8
+ -- stopped until the new application version is active.
9
+ DO $codex_boundaries_writer_drain_before_lock$
10
+ BEGIN
11
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app')
12
+ AND EXISTS (
13
+ SELECT 1
14
+ FROM pg_stat_activity
15
+ WHERE datname = current_database()
16
+ AND usename = 'opengeni_app'
17
+ AND pid <> pg_backend_pid()
18
+ )
19
+ THEN
20
+ RAISE EXCEPTION
21
+ 'Codex authentication boundaries activation requires all opengeni_app sessions to be stopped'
22
+ USING ERRCODE = '55000';
23
+ END IF;
24
+ END
25
+ $codex_boundaries_writer_drain_before_lock$;
26
+
27
+ LOCK TABLE "codex_subscription_credentials" IN ACCESS EXCLUSIVE MODE;
28
+ LOCK TABLE "session_history_items" IN ACCESS EXCLUSIVE MODE;
29
+ LOCK TABLE "agent_run_states" IN ACCESS EXCLUSIVE MODE;
30
+
31
+ DO $codex_boundaries_writer_drain_after_lock$
32
+ BEGIN
33
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app')
34
+ AND EXISTS (
35
+ SELECT 1
36
+ FROM pg_stat_activity
37
+ WHERE datname = current_database()
38
+ AND usename = 'opengeni_app'
39
+ AND pid <> pg_backend_pid()
40
+ )
41
+ THEN
42
+ RAISE EXCEPTION
43
+ 'Codex authentication boundaries activation requires all opengeni_app sessions to be stopped'
44
+ USING ERRCODE = '55000';
45
+ END IF;
46
+ END
47
+ $codex_boundaries_writer_drain_after_lock$;
48
+
49
+ -- One optional workspace credential for ChatGPT connected Apps. This pointer is
50
+ -- intentionally separate from inference rotation and starts unset.
51
+ CREATE UNIQUE INDEX "codex_subscription_credentials_workspace_account_id_idx"
52
+ ON "codex_subscription_credentials" ("workspace_id", "account_id", "id");
53
+
54
+ CREATE TABLE "codex_apps_settings" (
55
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
56
+ "account_id" uuid NOT NULL REFERENCES "managed_accounts"("id") ON DELETE CASCADE,
57
+ "workspace_id" uuid NOT NULL REFERENCES "workspaces"("id") ON DELETE CASCADE,
58
+ "credential_id" uuid,
59
+ "version" integer NOT NULL DEFAULT 1,
60
+ "designated_at" timestamptz,
61
+ "updated_at" timestamptz NOT NULL DEFAULT now(),
62
+ CONSTRAINT "codex_apps_settings_workspace_account_fk"
63
+ FOREIGN KEY ("workspace_id", "account_id")
64
+ REFERENCES "workspaces"("id", "account_id") ON DELETE CASCADE,
65
+ CONSTRAINT "codex_apps_settings_credential_scope_fk"
66
+ FOREIGN KEY ("workspace_id", "account_id", "credential_id")
67
+ REFERENCES "codex_subscription_credentials"("workspace_id", "account_id", "id")
68
+ ON DELETE CASCADE,
69
+ CONSTRAINT "codex_apps_settings_designation_shape_chk" CHECK (
70
+ "version" > 0 AND (
71
+ ("credential_id" IS NULL AND "designated_at" IS NULL)
72
+ OR
73
+ ("credential_id" IS NOT NULL AND "designated_at" IS NOT NULL)
74
+ )
75
+ )
76
+ );
77
+
78
+ CREATE UNIQUE INDEX "codex_apps_settings_workspace_idx"
79
+ ON "codex_apps_settings" ("workspace_id");
80
+
81
+ ALTER TABLE "codex_apps_settings" ENABLE ROW LEVEL SECURITY;
82
+ ALTER TABLE "codex_apps_settings" FORCE ROW LEVEL SECURITY;
83
+ CREATE POLICY workspace_isolation ON "codex_apps_settings"
84
+ USING (opengeni_private.workspace_rls_visible(account_id, workspace_id))
85
+ WITH CHECK (opengeni_private.workspace_rls_visible(account_id, workspace_id));
86
+
87
+ -- Remove the false credential/history relation and the obsolete
88
+ -- connector-aware inference cache. Provider rejection receipts remain.
89
+ ALTER TABLE "session_history_items"
90
+ DROP COLUMN "producer_codex_credential_id";
91
+ ALTER TABLE "agent_run_states"
92
+ DROP COLUMN "frozen_codex_credential_id";
93
+ ALTER TABLE "codex_subscription_credentials"
94
+ DROP COLUMN "connector_namespaces",
95
+ DROP COLUMN "connectors_checked_at";
96
+
97
+ DO $$
98
+ DECLARE
99
+ target_schema text := current_schema();
100
+ BEGIN
101
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
102
+ EXECUTE format(
103
+ 'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE %I.codex_apps_settings TO opengeni_app',
104
+ target_schema
105
+ );
106
+ END IF;
107
+ END $$;
@@ -0,0 +1,80 @@
1
+ -- deployment-mode: rolling
2
+ -- Historical, fully quiesced interruptions are audit evidence, not live control work.
3
+
4
+ SET lock_timeout = '5s';
5
+ SET statement_timeout = '10min';
6
+
7
+ DROP FUNCTION opengeni_private.claim_session_workflow_wakes(integer);
8
+
9
+ DO $migration$
10
+ DECLARE target_schema text := current_schema();
11
+ BEGIN
12
+ EXECUTE format($create$
13
+ CREATE FUNCTION opengeni_private.claim_session_workflow_wakes(p_limit integer)
14
+ RETURNS TABLE (
15
+ account_id uuid,
16
+ workspace_id uuid,
17
+ session_id uuid,
18
+ temporal_workflow_id text,
19
+ wake_revision bigint,
20
+ interruption_requested boolean
21
+ )
22
+ LANGUAGE plpgsql
23
+ SECURITY DEFINER
24
+ SET search_path = pg_catalog
25
+ AS $function$
26
+ BEGIN
27
+ RETURN QUERY
28
+ WITH due AS (
29
+ SELECT o.session_id
30
+ FROM %1$I.session_workflow_wake_outbox o
31
+ WHERE o.wake_revision > o.delivered_revision
32
+ AND o.next_attempt_at <= now()
33
+ ORDER BY o.next_attempt_at, o.updated_at, o.session_id
34
+ FOR UPDATE SKIP LOCKED
35
+ LIMIT greatest(1, least(coalesce(p_limit, 100), 1000))
36
+ )
37
+ UPDATE %1$I.session_workflow_wake_outbox o
38
+ SET attempts = o.attempts + 1,
39
+ next_attempt_at = now() + make_interval(
40
+ secs => least(300, greatest(1, power(2, least(o.attempts, 8))::integer))
41
+ ),
42
+ updated_at = now()
43
+ FROM due
44
+ WHERE o.session_id = due.session_id
45
+ RETURNING o.account_id, o.workspace_id, o.session_id,
46
+ o.temporal_workflow_id, o.wake_revision,
47
+ o.control_revision > o.delivered_revision
48
+ OR EXISTS (
49
+ SELECT 1
50
+ FROM %1$I.session_attempt_interruptions interruption
51
+ JOIN %1$I.session_turn_attempts attempt
52
+ ON attempt.workspace_id = interruption.workspace_id
53
+ AND attempt.session_id = interruption.session_id
54
+ AND attempt.id = interruption.attempt_id
55
+ WHERE interruption.workspace_id = o.workspace_id
56
+ AND interruption.session_id = o.session_id
57
+ AND (
58
+ interruption.state IN ('pending', 'delivered', 'acknowledged')
59
+ OR (
60
+ interruption.state IN ('settled', 'rejected_stale')
61
+ AND attempt.quiesced_at IS NULL
62
+ )
63
+ )
64
+ ) AS interruption_requested;
65
+ END $function$;
66
+ $create$, target_schema);
67
+ END $migration$;
68
+
69
+ REVOKE ALL ON FUNCTION opengeni_private.claim_session_workflow_wakes(integer) FROM PUBLIC;
70
+
71
+ DO $$
72
+ BEGIN
73
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
74
+ GRANT EXECUTE ON FUNCTION opengeni_private.claim_session_workflow_wakes(integer)
75
+ TO opengeni_app;
76
+ END IF;
77
+ END $$;
78
+
79
+ RESET statement_timeout;
80
+ RESET lock_timeout;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/db",
3
- "version": "0.27.8",
3
+ "version": "0.27.11",
4
4
  "description": "OpenGeni persistence: Drizzle schema, RLS-scoped query layer, the SQL migration runner, and role provisioning.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -50,9 +50,9 @@
50
50
  "prepublishOnly": "bash ../../scripts/prepublish-guard"
51
51
  },
52
52
  "dependencies": {
53
- "@opengeni/codex": "^0.2.10",
54
- "@opengeni/config": "^0.10.11",
55
- "@opengeni/contracts": "^0.38.1",
53
+ "@opengeni/codex": "^0.2.11",
54
+ "@opengeni/config": "^0.10.14",
55
+ "@opengeni/contracts": "^0.38.3",
56
56
  "@opengeni/network": "^0.2.0",
57
57
  "drizzle-orm": "^0.45.2",
58
58
  "postgres": "^3.4.7"
@@ -3,7 +3,7 @@
3
3
  // Hoisted here from apps/worker/src/activities/codex-auth.ts so BOTH the worker
4
4
  // (turn-time bearer for the streamed run) AND the api (the /wham/usage quota-bar
5
5
  // reads) drive ONE resolver — no duplicated refresh/CAS/single-flight logic. The
6
- // worker re-exports buildCodexTokenResolver from this module for back-compat, so
6
+ // worker re-exports buildCodexTokenResolver from @opengeni/db for back-compat, so
7
7
  // the agent-turn.ts call site is unchanged.
8
8
  //
9
9
  // Why @opengeni/db is the right home: the resolver only orchestrates accessors
@@ -35,15 +35,52 @@ import {
35
35
  refreshCodexToken,
36
36
  } from "@opengeni/codex";
37
37
  import { encryptEnvironmentValue } from "./environment-crypto";
38
- import {
39
- loadCodexCredentialForRun,
40
- recordCodexAccountUsage,
41
- recordCodexTokenRefresh,
42
- setCodexCredentialStatus,
43
- withCodexCredentialRefreshLock,
44
- type CodexCredentialForRun,
45
- type Database,
46
- } from "./index";
38
+ import type { Database } from "./database";
39
+
40
+ export type CodexCredentialTokens = {
41
+ accessToken: string;
42
+ refreshToken: string;
43
+ idToken: string;
44
+ };
45
+
46
+ export type CodexCredentialForRun = {
47
+ id: string;
48
+ version: number;
49
+ workspaceId: string;
50
+ tokens: CodexCredentialTokens;
51
+ chatgptAccountId: string | null;
52
+ scopes: string | null;
53
+ planType: string | null;
54
+ isFedramp: boolean;
55
+ expiresAt: Date | null;
56
+ lastRefreshAt: Date | null;
57
+ status: string;
58
+ lastError: string | null;
59
+ };
60
+
61
+ export type CodexAccountUsageSnapshot = {
62
+ primaryUsedPercent?: number | null;
63
+ primaryResetAt?: Date | null;
64
+ secondaryUsedPercent?: number | null;
65
+ secondaryResetAt?: Date | null;
66
+ checkedAt?: Date;
67
+ resetCreditAvailableCount?: number | null;
68
+ resetCreditsCheckedAt?: Date | null;
69
+ };
70
+
71
+ type CodexCredentialRefreshInput = {
72
+ id: string;
73
+ version: number;
74
+ workspaceId: string;
75
+ credentialEncrypted: string;
76
+ expiresAt: Date | null;
77
+ lastRefreshAt: Date;
78
+ };
79
+
80
+ type CodexCredentialStatusTarget = {
81
+ id: string;
82
+ version: number;
83
+ };
47
84
 
48
85
  // Single-flight per CREDENTIAL INSTANCE (row id + version), process-module scope.
49
86
  // Keying by the loaded credential's id+version — NOT by workspaceId alone (P1-b) —
@@ -142,25 +179,37 @@ export async function withCodexTokenDeadline<T>(
142
179
 
143
180
  // Dependencies are injectable so the lifecycle logic (single-flight, staleness,
144
181
  // needs_relogin transition) is unit-testable without a database. Production uses
145
- // the real db + codex functions via the default bag.
182
+ // the root composition wrapper supplies the real db + codex functions.
146
183
  export type CodexAuthDeps = {
147
- loadCredential: typeof loadCodexCredentialForRun;
148
- recordRefresh: typeof recordCodexTokenRefresh;
149
- setStatus: typeof setCodexCredentialStatus;
184
+ loadCredential: (
185
+ db: Database,
186
+ settings: Settings,
187
+ workspaceId: string,
188
+ credentialId: string,
189
+ ) => Promise<CodexCredentialForRun | null>;
190
+ recordRefresh: (db: Database, input: CodexCredentialRefreshInput) => Promise<boolean>;
191
+ setStatus: (
192
+ db: Database,
193
+ workspaceId: string,
194
+ status: "active" | "needs_relogin" | "error",
195
+ lastError: string | null,
196
+ target: CodexCredentialStatusTarget,
197
+ ) => Promise<boolean>;
150
198
  refresh: typeof refreshCodexToken;
151
199
  encrypt: typeof encryptEnvironmentValue;
152
200
  keyBytes: typeof environmentsEncryptionKeyBytes;
153
- withRefreshLock: typeof withCodexCredentialRefreshLock;
154
- };
155
-
156
- const defaultDeps: CodexAuthDeps = {
157
- loadCredential: loadCodexCredentialForRun,
158
- recordRefresh: recordCodexTokenRefresh,
159
- setStatus: setCodexCredentialStatus,
160
- refresh: refreshCodexToken,
161
- encrypt: encryptEnvironmentValue,
162
- keyBytes: environmentsEncryptionKeyBytes,
163
- withRefreshLock: withCodexCredentialRefreshLock,
201
+ withRefreshLock: <T>(
202
+ db: Database,
203
+ workspaceId: string,
204
+ credentialId: string,
205
+ fn: (lockedDb: Database) => Promise<T>,
206
+ ) => Promise<T>;
207
+ recordUsage?: (
208
+ db: Database,
209
+ workspaceId: string,
210
+ credentialId: string,
211
+ snapshot: CodexAccountUsageSnapshot,
212
+ ) => Promise<boolean>;
164
213
  };
165
214
 
166
215
  export function buildCodexTokenResolver(
@@ -173,7 +222,7 @@ export function buildCodexTokenResolver(
173
222
  // 0 rows against the now-inactive row — so a refresh racing a switch can never
174
223
  // clobber the newly-active account. The single-flight map needs zero change.
175
224
  credentialId: string,
176
- deps: CodexAuthDeps = defaultDeps,
225
+ deps: CodexAuthDeps,
177
226
  ): { getToken: () => Promise<CodexTokenSnapshot>; refresh: () => Promise<CodexTokenSnapshot> } {
178
227
  const snapshot = (cred: CodexCredentialForRun): CodexTokenSnapshot => ({
179
228
  accessToken: cred.tokens.accessToken,
@@ -336,9 +385,10 @@ export async function fetchCodexUsageForAccount(
336
385
  settings: Settings,
337
386
  workspaceId: string,
338
387
  credentialId: string,
388
+ deps: CodexAuthDeps,
339
389
  fetchImpl: CodexFetch = fetch,
340
390
  ): Promise<CodexUsagePayload> {
341
- const resolver = buildCodexTokenResolver(db, settings, workspaceId, credentialId);
391
+ const resolver = buildCodexTokenResolver(db, settings, workspaceId, credentialId, deps);
342
392
  let token: CodexTokenSnapshot;
343
393
  try {
344
394
  token = await resolver.getToken();
@@ -373,27 +423,29 @@ export async function fetchCodexUsageForAccount(
373
423
  // cached without erasing or falsely refreshing the last valid quota truth.
374
424
  // Cache-write is best-effort: a disconnect under us (false) or a transient
375
425
  // write error must NOT sink the freshly-read result we are about to return.
376
- await recordCodexAccountUsage(db, workspaceId, credentialId, {
377
- ...(parsedQuota
378
- ? {
379
- primaryUsedPercent: normalized.fiveHour?.percent ?? null,
380
- primaryResetAt: normalized.fiveHour?.resetAt
381
- ? new Date(normalized.fiveHour.resetAt)
382
- : null,
383
- secondaryUsedPercent: normalized.weekly?.percent ?? null,
384
- secondaryResetAt: normalized.weekly?.resetAt
385
- ? new Date(normalized.weekly.resetAt)
386
- : null,
387
- checkedAt,
388
- }
389
- : {}),
390
- ...(normalized.rateLimitResetCredits
391
- ? {
392
- resetCreditAvailableCount: normalized.rateLimitResetCredits.availableCount,
393
- resetCreditsCheckedAt: checkedAt,
394
- }
395
- : {}),
396
- }).catch(() => undefined);
426
+ await deps
427
+ .recordUsage?.(db, workspaceId, credentialId, {
428
+ ...(parsedQuota
429
+ ? {
430
+ primaryUsedPercent: normalized.fiveHour?.percent ?? null,
431
+ primaryResetAt: normalized.fiveHour?.resetAt
432
+ ? new Date(normalized.fiveHour.resetAt)
433
+ : null,
434
+ secondaryUsedPercent: normalized.weekly?.percent ?? null,
435
+ secondaryResetAt: normalized.weekly?.resetAt
436
+ ? new Date(normalized.weekly.resetAt)
437
+ : null,
438
+ checkedAt,
439
+ }
440
+ : {}),
441
+ ...(normalized.rateLimitResetCredits
442
+ ? {
443
+ resetCreditAvailableCount: normalized.rateLimitResetCredits.availableCount,
444
+ resetCreditsCheckedAt: checkedAt,
445
+ }
446
+ : {}),
447
+ })
448
+ .catch(() => undefined);
397
449
  }
398
450
 
399
451
  return normalized;
@@ -418,9 +470,10 @@ export async function fetchCodexRateLimitResetCreditsForAccount(
418
470
  settings: Settings,
419
471
  workspaceId: string,
420
472
  credentialId: string,
473
+ deps: CodexAuthDeps,
421
474
  fetchImpl: CodexFetch = fetch,
422
475
  ): Promise<CodexRateLimitResetCreditsAccountResult> {
423
- const resolver = buildCodexTokenResolver(db, settings, workspaceId, credentialId);
476
+ const resolver = buildCodexTokenResolver(db, settings, workspaceId, credentialId, deps);
424
477
  let token: CodexTokenSnapshot;
425
478
  try {
426
479
  token = await resolver.getToken();