@opengeni/db 0.9.3 → 0.10.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/{chunk-4LG5NBTC.js → chunk-P6PKXY5W.js} +93 -1
  2. package/dist/chunk-P6PKXY5W.js.map +1 -0
  3. package/dist/index.d.ts +3 -2
  4. package/dist/index.js +1332 -178
  5. package/dist/index.js.map +1 -1
  6. package/dist/provision-roles.d.ts +406 -32
  7. package/dist/{schema-CdPGTHlD.d.ts → schema-CqkzrBRS.d.ts} +513 -2
  8. package/dist/schema.d.ts +1 -1
  9. package/dist/schema.js +3 -1
  10. package/drizzle/0053_codex_credential_leases.sql +2 -2
  11. package/drizzle/0057_durable_queue_control.sql +1 -1
  12. package/drizzle/0061_session_workflow_wake_outbox.sql +1 -1
  13. package/drizzle/0062_session_list_snapshot_reaper.sql +1 -1
  14. package/drizzle/0063_session_control_mega_foundation.sql +1 -1
  15. package/drizzle/0064_rotation_strategy_sharded_backfill.sql +1 -1
  16. package/drizzle/0065_codex_subscription_overview.sql +168 -0
  17. package/drizzle/0065_session_tool_policy.sql +38 -0
  18. package/drizzle/0067_session_event_payload_bounds.sql +2 -2
  19. package/drizzle/0068_workspace_control_event_bounds.sql +2 -2
  20. package/drizzle/0069_session_event_history_backfill.sql +2 -2
  21. package/drizzle/0074_session_activity_revisions.sql +2 -2
  22. package/drizzle/0106_session_attempt_mcp_approval_policies.sql +29 -0
  23. package/drizzle/0107_host_export_lineage_contract.sql +381 -0
  24. package/drizzle/0108_fence_invalidated_warming_epochs.sql +76 -0
  25. package/package.json +5 -4
  26. package/src/codex-token-resolver.ts +175 -14
  27. package/src/connection-token-resolver.ts +143 -120
  28. package/src/event-payload-sanitizer.ts +32 -2
  29. package/src/index.ts +1888 -205
  30. package/src/schema.ts +107 -1
  31. package/src/session-control.ts +2 -0
  32. package/src/session-queue-commands.ts +94 -21
  33. package/dist/chunk-4LG5NBTC.js.map +0 -1
@@ -0,0 +1,76 @@
1
+ -- deployment-mode: rolling
2
+ -- Fence provider creates that outlive a failed or expired warming acquisition.
3
+ --
4
+ -- A warming row can be invalidated while a provider create is still
5
+ -- non-abortably in flight. Advancing lease_epoch in the same transaction that
6
+ -- exposes cold/draining means a successor can never reuse the old acquisition
7
+ -- epoch, so late record/commit/fail/cleanup callbacks fail their existing CAS.
8
+
9
+ CREATE OR REPLACE FUNCTION opengeni_private.reap_sandbox_leases(
10
+ p_viewer_holder_ttl_ms bigint,
11
+ p_turn_holder_ttl_ms bigint,
12
+ p_idle_grace_ms bigint
13
+ )
14
+ RETURNS TABLE (workspace_id uuid, sandbox_group_id uuid, instance_id text, lease_epoch integer)
15
+ LANGUAGE plpgsql
16
+ SECURITY DEFINER
17
+ AS $$
18
+ BEGIN
19
+ DELETE FROM sandbox_lease_holders h
20
+ WHERE h.kind = 'viewer'
21
+ AND h.last_heartbeat_at < now() - make_interval(secs => p_viewer_holder_ttl_ms / 1000.0);
22
+
23
+ IF p_turn_holder_ttl_ms > 0 THEN
24
+ DELETE FROM sandbox_lease_holders h
25
+ WHERE h.kind = 'turn'
26
+ AND h.last_heartbeat_at < now() - make_interval(secs => p_turn_holder_ttl_ms / 1000.0);
27
+ END IF;
28
+
29
+ UPDATE sandbox_leases L SET
30
+ refcount = c.total,
31
+ turn_holders = c.turns,
32
+ viewer_holders = c.viewers,
33
+ liveness = CASE WHEN L.liveness = 'warm' AND c.total = 0 AND c.turns = 0
34
+ THEN 'draining' ELSE L.liveness END,
35
+ expires_at = CASE WHEN L.liveness = 'warm' AND c.total = 0 AND c.turns = 0
36
+ THEN now() + make_interval(secs => p_idle_grace_ms / 1000.0)
37
+ ELSE L.expires_at END,
38
+ updated_at = now()
39
+ FROM (
40
+ SELECT L2.id,
41
+ (SELECT count(*) FROM sandbox_lease_holders h WHERE h.lease_id = L2.id)::int AS total,
42
+ (SELECT count(*) FROM sandbox_lease_holders h WHERE h.lease_id = L2.id AND h.kind = 'turn')::int AS turns,
43
+ (SELECT count(*) FROM sandbox_lease_holders h WHERE h.lease_id = L2.id AND h.kind = 'viewer')::int AS viewers
44
+ FROM sandbox_leases L2
45
+ ) c
46
+ WHERE L.id = c.id;
47
+
48
+ -- The old warming epoch is permanently closed before a successor can acquire.
49
+ UPDATE sandbox_leases AS L SET
50
+ liveness = 'cold', instance_id = NULL,
51
+ resume_backend_id = NULL, resume_state = NULL,
52
+ data_plane_url = NULL, terminal_data_plane_url = NULL,
53
+ lease_epoch = L.lease_epoch + 1,
54
+ updated_at = now()
55
+ WHERE L.liveness = 'warming' AND L.expires_at < now() AND L.instance_id IS NULL;
56
+
57
+ -- Keep an attributed provider id for the drain/terminate path, but fence the
58
+ -- expired creator before exposing the row as drainable.
59
+ UPDATE sandbox_leases AS L SET
60
+ liveness = 'draining',
61
+ refcount = 0,
62
+ turn_holders = 0,
63
+ viewer_holders = 0,
64
+ data_plane_url = NULL,
65
+ terminal_data_plane_url = NULL,
66
+ lease_epoch = L.lease_epoch + 1,
67
+ expires_at = now() - interval '1 millisecond',
68
+ updated_at = now()
69
+ WHERE L.liveness = 'warming' AND L.expires_at < now() AND L.instance_id IS NOT NULL;
70
+
71
+ RETURN QUERY
72
+ SELECT L.workspace_id, L.sandbox_group_id, L.instance_id, L.lease_epoch
73
+ FROM sandbox_leases L
74
+ WHERE L.liveness = 'draining' AND L.expires_at < now() AND L.refcount = 0;
75
+ END;
76
+ $$;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/db",
3
- "version": "0.9.3",
3
+ "version": "0.10.7",
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": {
@@ -49,9 +49,10 @@
49
49
  "prepublishOnly": "bash ../../scripts/prepublish-guard"
50
50
  },
51
51
  "dependencies": {
52
- "@opengeni/codex": "^0.2.5",
53
- "@opengeni/config": "^0.6.2",
54
- "@opengeni/contracts": "^0.15.0",
52
+ "@opengeni/codex": "^0.2.7",
53
+ "@opengeni/config": "^0.6.9",
54
+ "@opengeni/contracts": "^0.18.0",
55
+ "@opengeni/network": "^0.1.1",
55
56
  "drizzle-orm": "^0.45.2",
56
57
  "postgres": "^3.4.7"
57
58
  },
@@ -26,6 +26,10 @@ import {
26
26
  CodexReloginRequired,
27
27
  type CodexTokenSnapshot,
28
28
  type CodexUsagePayload,
29
+ type CodexFetch,
30
+ type CodexRateLimitResetCreditsDetails,
31
+ type ResetCreditFetchFailureReason,
32
+ fetchCodexRateLimitResetCredits,
29
33
  fetchCodexUsage,
30
34
  normalizeCodexUsage,
31
35
  refreshCodexToken,
@@ -49,6 +53,92 @@ import {
49
53
  // connected credential. Concurrent calls for the SAME credential still coalesce,
50
54
  // so the one-time refresh token is never double-spent.
51
55
  const inflight = new Map<string, Promise<CodexTokenSnapshot>>();
56
+ const CODEX_TOKEN_REFRESH_TIMEOUT_MS = 6_000;
57
+
58
+ export type CodexTokenDeadlineClock = {
59
+ setTimeout: (callback: () => void, delayMs: number) => ReturnType<typeof globalThis.setTimeout>;
60
+ clearTimeout: (handle: ReturnType<typeof globalThis.setTimeout>) => void;
61
+ };
62
+
63
+ export type CodexTokenDeadlineOptions = {
64
+ timeoutMs?: number | undefined;
65
+ signal?: AbortSignal | undefined;
66
+ clock?: CodexTokenDeadlineClock | undefined;
67
+ };
68
+
69
+ const systemCodexTokenDeadlineClock: CodexTokenDeadlineClock = {
70
+ setTimeout: (callback, delayMs) => globalThis.setTimeout(callback, delayMs),
71
+ clearTimeout: (handle) => globalThis.clearTimeout(handle),
72
+ };
73
+
74
+ /**
75
+ * Bound a refresh promise without abandoning its rejection handler when the
76
+ * deadline or cancellation wins. The provider promise is observed exactly
77
+ * once, while the observer itself always fulfills, so a late provider failure
78
+ * cannot become an unhandled rejection or replace the authoritative outcome.
79
+ */
80
+ export async function withCodexTokenDeadline<T>(
81
+ operation: Promise<T>,
82
+ options: CodexTokenDeadlineOptions = {},
83
+ ): Promise<T> {
84
+ const timeoutMs = options.timeoutMs ?? CODEX_TOKEN_REFRESH_TIMEOUT_MS;
85
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
86
+ throw new Error("Codex token refresh timeout must be positive");
87
+ }
88
+ const clock = options.clock ?? systemCodexTokenDeadlineClock;
89
+ const signal = options.signal;
90
+
91
+ return await new Promise<T>((resolve, reject) => {
92
+ let settled = false;
93
+ let timeout: ReturnType<typeof globalThis.setTimeout> | undefined;
94
+
95
+ const cleanup = (): void => {
96
+ if (timeout !== undefined) {
97
+ clock.clearTimeout(timeout);
98
+ timeout = undefined;
99
+ }
100
+ signal?.removeEventListener("abort", onAbort);
101
+ };
102
+
103
+ const settle = (
104
+ outcome: { kind: "resolve"; value: T } | { kind: "reject"; error: unknown },
105
+ ) => {
106
+ if (settled) return;
107
+ settled = true;
108
+ cleanup();
109
+ if (outcome.kind === "resolve") {
110
+ resolve(outcome.value);
111
+ } else {
112
+ reject(outcome.error);
113
+ }
114
+ };
115
+
116
+ const onAbort = (): void => {
117
+ settle({
118
+ kind: "reject",
119
+ error: signal?.reason ?? new Error("Codex token refresh cancelled"),
120
+ });
121
+ };
122
+
123
+ if (signal?.aborted) {
124
+ onAbort();
125
+ } else {
126
+ signal?.addEventListener("abort", onAbort, { once: true });
127
+ timeout = clock.setTimeout(
128
+ () => settle({ kind: "reject", error: new Error("Codex token refresh timed out") }),
129
+ timeoutMs,
130
+ );
131
+ }
132
+
133
+ // Do not use Promise.race here. Its derived promise can obscure which
134
+ // branch owns settlement, while this fulfillment-only observer makes the
135
+ // losing provider branch explicitly consumed after timeout/cancellation.
136
+ void Promise.resolve(operation).then(
137
+ (value) => settle({ kind: "resolve", value }),
138
+ (error) => settle({ kind: "reject", error }),
139
+ );
140
+ });
141
+ }
52
142
 
53
143
  // Dependencies are injectable so the lifecycle logic (single-flight, staleness,
54
144
  // needs_relogin transition) is unit-testable without a database. Production uses
@@ -96,7 +186,10 @@ export function buildCodexTokenResolver(
96
186
  cred: CodexCredentialForRun,
97
187
  ): Promise<CodexTokenSnapshot> => {
98
188
  try {
99
- const next = await deps.refresh(cred.tokens.refreshToken);
189
+ // Bound even injected/custom refresh implementations that ignore abort
190
+ // signals. The provider client has its own AbortController timeout; this
191
+ // outer fence ensures the DB advisory transaction cannot be held forever.
192
+ const next = await withCodexTokenDeadline(deps.refresh(cred.tokens.refreshToken));
100
193
  const tokens = {
101
194
  access_token: next.accessToken ?? cred.tokens.accessToken,
102
195
  refresh_token: next.refreshToken ?? cred.tokens.refreshToken,
@@ -219,6 +312,7 @@ function errorUsagePayload(reason?: "needs_relogin"): CodexUsagePayload {
219
312
  weekly: null,
220
313
  limitReached: false,
221
314
  fetchedAt: new Date().toISOString(),
315
+ rateLimitResetCredits: null,
222
316
  ...(reason ? { reason } : {}),
223
317
  };
224
318
  }
@@ -242,6 +336,7 @@ export async function fetchCodexUsageForAccount(
242
336
  settings: Settings,
243
337
  workspaceId: string,
244
338
  credentialId: string,
339
+ fetchImpl: CodexFetch = fetch,
245
340
  ): Promise<CodexUsagePayload> {
246
341
  const resolver = buildCodexTokenResolver(db, settings, workspaceId, credentialId);
247
342
  let token: CodexTokenSnapshot;
@@ -253,12 +348,15 @@ export async function fetchCodexUsageForAccount(
253
348
 
254
349
  let normalized: CodexUsagePayload;
255
350
  try {
256
- const usage = await fetchCodexUsage({
257
- accessToken: token.accessToken,
258
- chatgptAccountId: token.chatgptAccountId,
259
- isFedramp: token.isFedramp,
260
- clientVersion: CODEX_CLIENT_VERSION,
261
- });
351
+ const usage = await fetchCodexUsage(
352
+ {
353
+ accessToken: token.accessToken,
354
+ chatgptAccountId: token.chatgptAccountId,
355
+ isFedramp: token.isFedramp,
356
+ clientVersion: CODEX_CLIENT_VERSION,
357
+ },
358
+ fetchImpl,
359
+ );
262
360
  normalized = normalizeCodexUsage(usage.status, usage.payload);
263
361
  } catch {
264
362
  // A network throw on the /wham/usage read must surface as an error PAYLOAD
@@ -266,17 +364,80 @@ export async function fetchCodexUsageForAccount(
266
364
  return errorUsagePayload();
267
365
  }
268
366
 
269
- if (normalized.fiveHour || normalized.weekly) {
367
+ const parsedQuota =
368
+ normalized.status !== "error" && (normalized.fiveHour != null || normalized.weekly != null);
369
+ if (parsedQuota || normalized.rateLimitResetCredits) {
370
+ const checkedAt = new Date();
371
+ // Quota windows and reset-summary freshness are independent. A malformed
372
+ // usage body can still carry a syntactically valid count; that count may be
373
+ // cached without erasing or falsely refreshing the last valid quota truth.
270
374
  // Cache-write is best-effort: a disconnect under us (false) or a transient
271
- // write error must NOT sink the freshly-read usage we are about to return.
375
+ // write error must NOT sink the freshly-read result we are about to return.
272
376
  await recordCodexAccountUsage(db, workspaceId, credentialId, {
273
- primaryUsedPercent: normalized.fiveHour?.percent ?? null,
274
- primaryResetAt: normalized.fiveHour?.resetAt ? new Date(normalized.fiveHour.resetAt) : null,
275
- secondaryUsedPercent: normalized.weekly?.percent ?? null,
276
- secondaryResetAt: normalized.weekly?.resetAt ? new Date(normalized.weekly.resetAt) : null,
277
- checkedAt: new Date(),
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
+ : {}),
278
396
  }).catch(() => undefined);
279
397
  }
280
398
 
281
399
  return normalized;
282
400
  }
401
+
402
+ export type CodexRateLimitResetCreditsAccountResult =
403
+ | { ok: true; status: number; details: CodexRateLimitResetCreditsDetails }
404
+ | {
405
+ ok: false;
406
+ status: number;
407
+ reason: ResetCreditFetchFailureReason | "needs_relogin";
408
+ };
409
+
410
+ /**
411
+ * Fresh detailed reset-credit inventory for one exact workspace credential.
412
+ * The token is refreshed through the same resolver as usage and never escapes
413
+ * this server-side function. Detailed rows are returned to the route only and
414
+ * are never persisted as redemption authority.
415
+ */
416
+ export async function fetchCodexRateLimitResetCreditsForAccount(
417
+ db: Database,
418
+ settings: Settings,
419
+ workspaceId: string,
420
+ credentialId: string,
421
+ fetchImpl: CodexFetch = fetch,
422
+ ): Promise<CodexRateLimitResetCreditsAccountResult> {
423
+ const resolver = buildCodexTokenResolver(db, settings, workspaceId, credentialId);
424
+ let token: CodexTokenSnapshot;
425
+ try {
426
+ token = await resolver.getToken();
427
+ } catch (error) {
428
+ return {
429
+ ok: false,
430
+ status: 0,
431
+ reason: error instanceof CodexReloginRequired ? "needs_relogin" : "network_error",
432
+ };
433
+ }
434
+ return await fetchCodexRateLimitResetCredits(
435
+ {
436
+ accessToken: token.accessToken,
437
+ chatgptAccountId: token.chatgptAccountId,
438
+ isFedramp: token.isFedramp,
439
+ clientVersion: CODEX_CLIENT_VERSION,
440
+ },
441
+ fetchImpl,
442
+ );
443
+ }