@opengeni/db 0.6.0 → 0.7.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/dist/{chunk-OGCE6O2X.js → chunk-7LDU7F5P.js} +31 -3
- package/dist/chunk-7LDU7F5P.js.map +1 -0
- package/dist/chunk-O3D7DABC.js +2314 -0
- package/dist/chunk-O3D7DABC.js.map +1 -0
- package/dist/{chunk-57MLICFR.js → chunk-YFQ7SGE4.js} +18 -6
- package/dist/chunk-YFQ7SGE4.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +13129 -3496
- package/dist/index.js.map +1 -1
- package/dist/migrate.js +1 -1
- package/dist/provision-roles.d.ts +1836 -222
- package/dist/provision-roles.js +1 -1
- package/dist/{schema-Dsz6UHNv.d.ts → schema-Dp2MbBxx.d.ts} +7480 -3305
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +33 -5
- package/drizzle/0043_credit_balance_observability.sql +22 -0
- package/drizzle/0044_reap_dead_turn_holders.sql +104 -0
- package/drizzle/0045_workspace_captures.sql +83 -0
- package/drizzle/0045_workspace_memory_v1.sql +71 -0
- package/drizzle/0046_variable_sets_rename.sql +56 -0
- package/drizzle/0047_rigs.sql +151 -0
- package/drizzle/0048_rig_runtime.sql +9 -0
- package/drizzle/0049_enrollment_went_offline.sql +28 -0
- package/drizzle/0050_enrollment_op_stream.sql +1 -0
- package/drizzle/0051_codex_pin_source.sql +48 -0
- package/drizzle/0052_file_upload_cleanup.sql +91 -0
- package/drizzle/0053_codex_credential_leases.sql +230 -0
- package/drizzle/0054_session_pins.sql +85 -0
- package/drizzle/0055_session_list_snapshots.sql +73 -0
- package/drizzle/0056_workspace_model_policies.sql +48 -0
- package/drizzle/0057_durable_queue_control.sql +536 -0
- package/drizzle/0058_turn_admission_usage_enrollment.sql +158 -0
- package/drizzle/0059_workspace_pause_control_kind.sql +12 -0
- package/drizzle/0060_session_system_update_deferral.sql +10 -0
- package/drizzle/0061_session_workflow_wake_outbox.sql +157 -0
- package/drizzle/0062_session_list_snapshot_reaper.sql +48 -0
- package/package.json +17 -13
- package/src/codex-token-resolver.ts +58 -23
- package/src/connection-token-resolver.ts +146 -57
- package/src/environment-crypto.ts +5 -1
- package/src/event-payload-sanitizer.ts +29 -1
- package/src/index.ts +22460 -6492
- package/src/memory-domain.ts +218 -0
- package/src/migrate.ts +58 -3
- package/src/provision-roles.ts +46 -17
- package/src/schema.ts +2557 -1121
- package/src/session-control-cutover-audit.ts +1203 -0
- package/dist/chunk-57MLICFR.js.map +0 -1
- package/dist/chunk-OGCE6O2X.js.map +0 -1
- package/dist/chunk-ZIUCA2IO.js +0 -1268
- package/dist/chunk-ZIUCA2IO.js.map +0 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
-- One-way production remediation after the 0057 session-control cutover.
|
|
2
|
+
--
|
|
3
|
+
-- 1. Provider response usage has exactly one authoritative current event. SDK
|
|
4
|
+
-- wrapper duplicates stay in the audit log with an explicit association.
|
|
5
|
+
-- 2. Workflow repair enrolls every durable state that needs a session workflow,
|
|
6
|
+
-- including approval/capacity waits and pending control settlement.
|
|
7
|
+
|
|
8
|
+
ALTER TABLE "session_events"
|
|
9
|
+
DROP CONSTRAINT "session_events_turn_association_check";
|
|
10
|
+
|
|
11
|
+
ALTER TABLE "session_events"
|
|
12
|
+
ADD COLUMN "duplicate_of_event_id" uuid,
|
|
13
|
+
ADD COLUMN "duplicate_reason" text;
|
|
14
|
+
|
|
15
|
+
ALTER TABLE "session_events"
|
|
16
|
+
ADD CONSTRAINT "session_events_duplicate_of_event_fk"
|
|
17
|
+
FOREIGN KEY ("duplicate_of_event_id")
|
|
18
|
+
REFERENCES "session_events"("id") ON DELETE RESTRICT;
|
|
19
|
+
|
|
20
|
+
ALTER TABLE "session_events"
|
|
21
|
+
ADD CONSTRAINT "session_events_turn_association_check"
|
|
22
|
+
CHECK (
|
|
23
|
+
"turn_association" IS NULL
|
|
24
|
+
OR "turn_association" IN ('current', 'late_rejected', 'duplicate')
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
-- Keep the earliest event as the authoritative observation. Every later event
|
|
28
|
+
-- for the same turn/provider-response source remains queryable and points to
|
|
29
|
+
-- that canonical row; nothing is deleted or disguised as a late attempt write.
|
|
30
|
+
WITH ranked AS (
|
|
31
|
+
SELECT
|
|
32
|
+
e."id",
|
|
33
|
+
first_value(e."id") OVER (
|
|
34
|
+
PARTITION BY
|
|
35
|
+
e."workspace_id",
|
|
36
|
+
e."session_id",
|
|
37
|
+
e."turn_id",
|
|
38
|
+
e."payload" ->> 'sourceKey'
|
|
39
|
+
ORDER BY e."sequence", e."id"
|
|
40
|
+
) AS "canonical_id",
|
|
41
|
+
row_number() OVER (
|
|
42
|
+
PARTITION BY
|
|
43
|
+
e."workspace_id",
|
|
44
|
+
e."session_id",
|
|
45
|
+
e."turn_id",
|
|
46
|
+
e."payload" ->> 'sourceKey'
|
|
47
|
+
ORDER BY e."sequence", e."id"
|
|
48
|
+
) AS "ordinal"
|
|
49
|
+
FROM "session_events" e
|
|
50
|
+
WHERE e."type" = 'agent.model.usage'
|
|
51
|
+
AND e."turn_association" = 'current'
|
|
52
|
+
AND e."turn_id" IS NOT NULL
|
|
53
|
+
AND nullif(e."payload" ->> 'sourceKey', '') IS NOT NULL
|
|
54
|
+
)
|
|
55
|
+
UPDATE "session_events" e
|
|
56
|
+
SET "turn_association" = 'duplicate',
|
|
57
|
+
"duplicate_of_event_id" = ranked."canonical_id",
|
|
58
|
+
"duplicate_reason" = 'duplicate_provider_response_usage'
|
|
59
|
+
FROM ranked
|
|
60
|
+
WHERE e."id" = ranked."id"
|
|
61
|
+
AND ranked."ordinal" > 1;
|
|
62
|
+
|
|
63
|
+
ALTER TABLE "session_events"
|
|
64
|
+
ADD CONSTRAINT "session_events_duplicate_classification_check"
|
|
65
|
+
CHECK (
|
|
66
|
+
(
|
|
67
|
+
"turn_association" = 'duplicate'
|
|
68
|
+
AND "type" = 'agent.model.usage'
|
|
69
|
+
AND "duplicate_of_event_id" IS NOT NULL
|
|
70
|
+
AND "duplicate_of_event_id" <> "id"
|
|
71
|
+
AND nullif("duplicate_reason", '') IS NOT NULL
|
|
72
|
+
)
|
|
73
|
+
OR (
|
|
74
|
+
"turn_association" IS DISTINCT FROM 'duplicate'
|
|
75
|
+
AND "duplicate_of_event_id" IS NULL
|
|
76
|
+
AND "duplicate_reason" IS NULL
|
|
77
|
+
)
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
CREATE UNIQUE INDEX "session_events_current_model_usage_source_uq"
|
|
81
|
+
ON "session_events" (
|
|
82
|
+
"workspace_id",
|
|
83
|
+
"session_id",
|
|
84
|
+
"turn_id",
|
|
85
|
+
(("payload" ->> 'sourceKey'))
|
|
86
|
+
)
|
|
87
|
+
WHERE "type" = 'agent.model.usage'
|
|
88
|
+
AND "turn_association" = 'current'
|
|
89
|
+
AND "turn_id" IS NOT NULL
|
|
90
|
+
AND nullif("payload" ->> 'sourceKey', '') IS NOT NULL;
|
|
91
|
+
|
|
92
|
+
-- The old name described database claimability, not the actual operational
|
|
93
|
+
-- contract. A workflow must also be enrolled while it waits for approval or
|
|
94
|
+
-- capacity, and any pending Pause/Steer control needs a workflow to settle it.
|
|
95
|
+
DROP FUNCTION opengeni_private.list_claimable_sessions(integer);
|
|
96
|
+
|
|
97
|
+
CREATE FUNCTION opengeni_private.list_enrollable_sessions(p_limit integer)
|
|
98
|
+
RETURNS TABLE (
|
|
99
|
+
account_id uuid,
|
|
100
|
+
workspace_id uuid,
|
|
101
|
+
session_id uuid,
|
|
102
|
+
temporal_workflow_id text
|
|
103
|
+
)
|
|
104
|
+
LANGUAGE sql
|
|
105
|
+
SECURITY DEFINER
|
|
106
|
+
AS $$
|
|
107
|
+
SELECT DISTINCT
|
|
108
|
+
s.account_id,
|
|
109
|
+
s.workspace_id,
|
|
110
|
+
s.id,
|
|
111
|
+
coalesce(s.temporal_workflow_id, 'session-' || s.id::text)
|
|
112
|
+
FROM sessions s
|
|
113
|
+
JOIN workspaces w ON w.id = s.workspace_id
|
|
114
|
+
WHERE
|
|
115
|
+
-- Controls are durable intent. Even a closed inference gate must enroll a
|
|
116
|
+
-- workflow long enough to settle the pending Pause/Steer fence.
|
|
117
|
+
s.pending_control_event_id IS NOT NULL
|
|
118
|
+
OR (
|
|
119
|
+
s.control_state = 'active'
|
|
120
|
+
AND (
|
|
121
|
+
w.inference_state = 'active'
|
|
122
|
+
OR s.workspace_run_exception_generation = w.inference_generation
|
|
123
|
+
)
|
|
124
|
+
AND (
|
|
125
|
+
EXISTS (
|
|
126
|
+
SELECT 1 FROM session_turns t
|
|
127
|
+
WHERE t.workspace_id = s.workspace_id
|
|
128
|
+
AND t.session_id = s.id
|
|
129
|
+
AND t.status IN ('queued', 'recovering', 'waiting_capacity', 'requires_action')
|
|
130
|
+
)
|
|
131
|
+
OR EXISTS (
|
|
132
|
+
SELECT 1 FROM session_system_updates u
|
|
133
|
+
WHERE u.workspace_id = s.workspace_id
|
|
134
|
+
AND u.session_id = s.id
|
|
135
|
+
AND u.state = 'pending'
|
|
136
|
+
)
|
|
137
|
+
OR EXISTS (
|
|
138
|
+
SELECT 1 FROM session_goals g
|
|
139
|
+
WHERE g.workspace_id = s.workspace_id
|
|
140
|
+
AND g.session_id = s.id
|
|
141
|
+
AND g.status = 'active'
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
ORDER BY s.id
|
|
146
|
+
LIMIT greatest(1, least(coalesce(p_limit, 1000), 10000));
|
|
147
|
+
$$;
|
|
148
|
+
|
|
149
|
+
REVOKE ALL ON FUNCTION opengeni_private.list_enrollable_sessions(integer) FROM PUBLIC;
|
|
150
|
+
|
|
151
|
+
DO $$
|
|
152
|
+
DECLARE target_schema text := current_schema();
|
|
153
|
+
BEGIN
|
|
154
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
|
|
155
|
+
GRANT EXECUTE ON FUNCTION opengeni_private.list_enrollable_sessions(integer)
|
|
156
|
+
TO opengeni_app;
|
|
157
|
+
END IF;
|
|
158
|
+
END $$;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- Workspace Pause is a first-class durable control kind. The 0057 constraint
|
|
2
|
+
-- predates that control path and must match the canonical runtime state machine.
|
|
3
|
+
|
|
4
|
+
ALTER TABLE "sessions"
|
|
5
|
+
DROP CONSTRAINT "sessions_pending_control_kind_check";
|
|
6
|
+
|
|
7
|
+
ALTER TABLE "sessions"
|
|
8
|
+
ADD CONSTRAINT "sessions_pending_control_kind_check"
|
|
9
|
+
CHECK (
|
|
10
|
+
"pending_control_kind" IS NULL
|
|
11
|
+
OR "pending_control_kind" IN ('pause', 'workspace_pause', 'steer')
|
|
12
|
+
);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- A failed internal-only inference preserves ordinary internal updates without
|
|
2
|
+
-- making them independently runnable again. They become eligible when a real
|
|
3
|
+
-- prompt or a genuinely new pending internal update starts the next inference.
|
|
4
|
+
|
|
5
|
+
ALTER TABLE "session_system_updates"
|
|
6
|
+
DROP CONSTRAINT "system_updates_state_check";
|
|
7
|
+
|
|
8
|
+
ALTER TABLE "session_system_updates"
|
|
9
|
+
ADD CONSTRAINT "system_updates_state_check"
|
|
10
|
+
CHECK ("state" IN ('pending', 'deferred', 'delivered', 'cancelled', 'failed'));
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
-- OPE-50: replace the blind periodic scan of "enrollable" sessions with a
|
|
2
|
+
-- transactional, revisioned, coalescing delivery ledger. Postgres is the
|
|
3
|
+
-- durable work source; a Temporal signal is an idempotent nudge.
|
|
4
|
+
|
|
5
|
+
CREATE TABLE "session_workflow_wake_outbox" (
|
|
6
|
+
"session_id" uuid PRIMARY KEY,
|
|
7
|
+
"account_id" uuid NOT NULL,
|
|
8
|
+
"workspace_id" uuid NOT NULL,
|
|
9
|
+
"temporal_workflow_id" text NOT NULL,
|
|
10
|
+
"wake_revision" bigint NOT NULL DEFAULT 1,
|
|
11
|
+
"delivered_revision" bigint NOT NULL DEFAULT 0,
|
|
12
|
+
"reason" text NOT NULL,
|
|
13
|
+
"attempts" integer NOT NULL DEFAULT 0,
|
|
14
|
+
"next_attempt_at" timestamptz NOT NULL DEFAULT now(),
|
|
15
|
+
"last_error" text,
|
|
16
|
+
"created_at" timestamptz NOT NULL DEFAULT now(),
|
|
17
|
+
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
|
18
|
+
CONSTRAINT "session_workflow_wake_outbox_revision_check"
|
|
19
|
+
CHECK ("wake_revision" > 0 AND "delivered_revision" >= 0
|
|
20
|
+
AND "delivered_revision" <= "wake_revision"),
|
|
21
|
+
CONSTRAINT "session_workflow_wake_outbox_workspace_account_fk"
|
|
22
|
+
FOREIGN KEY ("workspace_id", "account_id")
|
|
23
|
+
REFERENCES "workspaces"("id", "account_id") ON DELETE CASCADE,
|
|
24
|
+
CONSTRAINT "session_workflow_wake_outbox_workspace_session_fk"
|
|
25
|
+
FOREIGN KEY ("workspace_id", "session_id")
|
|
26
|
+
REFERENCES "sessions"("workspace_id", "id") ON DELETE CASCADE
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
CREATE UNIQUE INDEX "session_workflow_wake_outbox_workspace_session_uq"
|
|
30
|
+
ON "session_workflow_wake_outbox" ("workspace_id", "session_id");
|
|
31
|
+
CREATE INDEX "session_workflow_wake_outbox_pending_idx"
|
|
32
|
+
ON "session_workflow_wake_outbox" ("next_attempt_at", "updated_at", "session_id")
|
|
33
|
+
WHERE "wake_revision" > "delivered_revision";
|
|
34
|
+
|
|
35
|
+
ALTER TABLE "session_workflow_wake_outbox" ENABLE ROW LEVEL SECURITY;
|
|
36
|
+
ALTER TABLE "session_workflow_wake_outbox" FORCE ROW LEVEL SECURITY;
|
|
37
|
+
CREATE POLICY workspace_isolation ON "session_workflow_wake_outbox"
|
|
38
|
+
USING (opengeni_private.workspace_rls_visible(account_id, workspace_id))
|
|
39
|
+
WITH CHECK (opengeni_private.workspace_rls_visible(account_id, workspace_id));
|
|
40
|
+
|
|
41
|
+
-- Workspace control retries now persist the exact control deliveries they own.
|
|
42
|
+
-- Canonicalize pre-cutover receipts once so runtime code has one result shape;
|
|
43
|
+
-- any still-pending historical control is independently seeded below.
|
|
44
|
+
UPDATE "runtime_control_operations"
|
|
45
|
+
SET "result" = jsonb_set("result", '{controls}', '[]'::jsonb, true)
|
|
46
|
+
WHERE "scope" = 'workspace'
|
|
47
|
+
AND jsonb_typeof("result") = 'object'
|
|
48
|
+
AND NOT ("result" ? 'controls');
|
|
49
|
+
|
|
50
|
+
-- Claim only committed revisions that still need delivery. The due timestamp
|
|
51
|
+
-- is advanced before delivery, so a process death becomes retryable after the
|
|
52
|
+
-- bounded backoff. Acknowledgements are revision-scoped: an old delivery can
|
|
53
|
+
-- never hide a newer committed wake.
|
|
54
|
+
DO $migration$
|
|
55
|
+
DECLARE target_schema text := current_schema();
|
|
56
|
+
BEGIN
|
|
57
|
+
EXECUTE format($create$
|
|
58
|
+
CREATE FUNCTION opengeni_private.claim_session_workflow_wakes(p_limit integer)
|
|
59
|
+
RETURNS TABLE (
|
|
60
|
+
account_id uuid,
|
|
61
|
+
workspace_id uuid,
|
|
62
|
+
session_id uuid,
|
|
63
|
+
temporal_workflow_id text,
|
|
64
|
+
wake_revision bigint,
|
|
65
|
+
control_event_id uuid
|
|
66
|
+
)
|
|
67
|
+
LANGUAGE plpgsql
|
|
68
|
+
SECURITY DEFINER
|
|
69
|
+
SET search_path = pg_catalog
|
|
70
|
+
AS $function$
|
|
71
|
+
BEGIN
|
|
72
|
+
RETURN QUERY
|
|
73
|
+
WITH due AS (
|
|
74
|
+
SELECT o.session_id
|
|
75
|
+
FROM %1$I.session_workflow_wake_outbox o
|
|
76
|
+
WHERE o.wake_revision > o.delivered_revision
|
|
77
|
+
AND o.next_attempt_at <= now()
|
|
78
|
+
ORDER BY o.next_attempt_at, o.updated_at, o.session_id
|
|
79
|
+
FOR UPDATE SKIP LOCKED
|
|
80
|
+
LIMIT greatest(1, least(coalesce(p_limit, 100), 1000))
|
|
81
|
+
)
|
|
82
|
+
UPDATE %1$I.session_workflow_wake_outbox o
|
|
83
|
+
SET attempts = o.attempts + 1,
|
|
84
|
+
next_attempt_at = now() + make_interval(
|
|
85
|
+
secs => least(300, greatest(1, power(2, least(o.attempts, 8))::integer))
|
|
86
|
+
),
|
|
87
|
+
updated_at = now()
|
|
88
|
+
FROM due
|
|
89
|
+
WHERE o.session_id = due.session_id
|
|
90
|
+
RETURNING o.account_id, o.workspace_id, o.session_id,
|
|
91
|
+
o.temporal_workflow_id, o.wake_revision,
|
|
92
|
+
(SELECT s.pending_control_event_id FROM %1$I.sessions s WHERE s.id = o.session_id)
|
|
93
|
+
AS control_event_id;
|
|
94
|
+
END $function$;
|
|
95
|
+
$create$, target_schema);
|
|
96
|
+
END $migration$;
|
|
97
|
+
REVOKE ALL ON FUNCTION opengeni_private.claim_session_workflow_wakes(integer) FROM PUBLIC;
|
|
98
|
+
|
|
99
|
+
-- One-time cutover seed. This is intentionally the final use of an eligibility
|
|
100
|
+
-- scan: it converts already-committed pre-outbox work into explicit revisions.
|
|
101
|
+
-- Runtime repair after this migration reads only the outbox.
|
|
102
|
+
INSERT INTO "session_workflow_wake_outbox" (
|
|
103
|
+
"session_id", "account_id", "workspace_id", "temporal_workflow_id", "reason"
|
|
104
|
+
)
|
|
105
|
+
SELECT DISTINCT
|
|
106
|
+
s."id",
|
|
107
|
+
s."account_id",
|
|
108
|
+
s."workspace_id",
|
|
109
|
+
coalesce(s."temporal_workflow_id", 'session-' || s."id"::text),
|
|
110
|
+
'cutover_seed'
|
|
111
|
+
FROM "sessions" s
|
|
112
|
+
JOIN "workspaces" w ON w."id" = s."workspace_id"
|
|
113
|
+
WHERE
|
|
114
|
+
s."pending_control_event_id" IS NOT NULL
|
|
115
|
+
OR (
|
|
116
|
+
s."control_state" = 'active'
|
|
117
|
+
AND (
|
|
118
|
+
w."inference_state" = 'active'
|
|
119
|
+
OR s."workspace_run_exception_generation" = w."inference_generation"
|
|
120
|
+
)
|
|
121
|
+
AND (
|
|
122
|
+
EXISTS (
|
|
123
|
+
SELECT 1 FROM "session_turns" t
|
|
124
|
+
WHERE t."workspace_id" = s."workspace_id"
|
|
125
|
+
AND t."session_id" = s."id"
|
|
126
|
+
AND t."status" IN ('queued', 'recovering', 'waiting_capacity', 'requires_action')
|
|
127
|
+
)
|
|
128
|
+
OR EXISTS (
|
|
129
|
+
SELECT 1 FROM "session_system_updates" u
|
|
130
|
+
WHERE u."workspace_id" = s."workspace_id"
|
|
131
|
+
AND u."session_id" = s."id"
|
|
132
|
+
AND u."state" = 'pending'
|
|
133
|
+
)
|
|
134
|
+
OR EXISTS (
|
|
135
|
+
SELECT 1 FROM "session_goals" g
|
|
136
|
+
WHERE g."workspace_id" = s."workspace_id"
|
|
137
|
+
AND g."session_id" = s."id"
|
|
138
|
+
AND g."status" = 'active'
|
|
139
|
+
)
|
|
140
|
+
OR s."compact_requested" = true
|
|
141
|
+
)
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
DROP FUNCTION opengeni_private.list_enrollable_sessions(integer);
|
|
145
|
+
|
|
146
|
+
DO $$
|
|
147
|
+
DECLARE target_schema text := current_schema();
|
|
148
|
+
BEGIN
|
|
149
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
|
|
150
|
+
EXECUTE format(
|
|
151
|
+
'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE %I.session_workflow_wake_outbox TO opengeni_app',
|
|
152
|
+
target_schema
|
|
153
|
+
);
|
|
154
|
+
GRANT EXECUTE ON FUNCTION opengeni_private.claim_session_workflow_wakes(integer)
|
|
155
|
+
TO opengeni_app;
|
|
156
|
+
END IF;
|
|
157
|
+
END $$;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
-- OPE-44: session list reads are serializable and subject-scoped. Performing a
|
|
2
|
+
-- global expired-row delete inside every read makes concurrent API replicas
|
|
3
|
+
-- conflict on the same rows. Move TTL cleanup to one bounded SKIP LOCKED
|
|
4
|
+
-- reaper operation and keep the request transaction strictly request-local.
|
|
5
|
+
|
|
6
|
+
CREATE INDEX "session_list_snapshots_expiry_reaper_idx"
|
|
7
|
+
ON "session_list_snapshots" ("expires_at", "id");
|
|
8
|
+
|
|
9
|
+
DO $migration$
|
|
10
|
+
DECLARE target_schema text := current_schema();
|
|
11
|
+
BEGIN
|
|
12
|
+
EXECUTE format($create$
|
|
13
|
+
CREATE FUNCTION opengeni_private.reap_expired_session_list_snapshots(p_limit integer)
|
|
14
|
+
RETURNS integer
|
|
15
|
+
LANGUAGE plpgsql
|
|
16
|
+
SECURITY DEFINER
|
|
17
|
+
SET search_path = pg_catalog
|
|
18
|
+
AS $function$
|
|
19
|
+
DECLARE deleted_count integer;
|
|
20
|
+
BEGIN
|
|
21
|
+
WITH victims AS (
|
|
22
|
+
SELECT s.id
|
|
23
|
+
FROM %1$I.session_list_snapshots s
|
|
24
|
+
WHERE s.expires_at < now()
|
|
25
|
+
ORDER BY s.expires_at, s.id
|
|
26
|
+
FOR UPDATE SKIP LOCKED
|
|
27
|
+
LIMIT greatest(1, least(coalesce(p_limit, 500), 5000))
|
|
28
|
+
), deleted AS (
|
|
29
|
+
DELETE FROM %1$I.session_list_snapshots s
|
|
30
|
+
USING victims
|
|
31
|
+
WHERE s.id = victims.id
|
|
32
|
+
RETURNING s.id
|
|
33
|
+
)
|
|
34
|
+
SELECT count(*)::integer INTO deleted_count FROM deleted;
|
|
35
|
+
RETURN deleted_count;
|
|
36
|
+
END $function$;
|
|
37
|
+
$create$, target_schema);
|
|
38
|
+
END $migration$;
|
|
39
|
+
|
|
40
|
+
REVOKE ALL ON FUNCTION opengeni_private.reap_expired_session_list_snapshots(integer) FROM PUBLIC;
|
|
41
|
+
|
|
42
|
+
DO $$
|
|
43
|
+
BEGIN
|
|
44
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
|
|
45
|
+
GRANT EXECUTE ON FUNCTION opengeni_private.reap_expired_session_list_snapshots(integer)
|
|
46
|
+
TO opengeni_app;
|
|
47
|
+
END IF;
|
|
48
|
+
END $$;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/db",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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": {
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
"url": "git+https://github.com/Cloudgeni-ai/opengeni.git",
|
|
9
9
|
"directory": "packages/db"
|
|
10
10
|
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"src",
|
|
14
|
+
"drizzle"
|
|
15
|
+
],
|
|
11
16
|
"type": "module",
|
|
12
17
|
"sideEffects": false,
|
|
13
18
|
"main": "./dist/index.js",
|
|
@@ -26,19 +31,15 @@
|
|
|
26
31
|
"types": "./dist/migrate.d.ts",
|
|
27
32
|
"import": "./dist/migrate.js"
|
|
28
33
|
},
|
|
34
|
+
"./session-control-cutover-audit": {
|
|
35
|
+
"types": "./dist/session-control-cutover-audit.d.ts",
|
|
36
|
+
"import": "./dist/session-control-cutover-audit.js"
|
|
37
|
+
},
|
|
29
38
|
"./provision-roles": {
|
|
30
39
|
"types": "./dist/provision-roles.d.ts",
|
|
31
40
|
"import": "./dist/provision-roles.js"
|
|
32
41
|
}
|
|
33
42
|
},
|
|
34
|
-
"files": [
|
|
35
|
-
"dist",
|
|
36
|
-
"src",
|
|
37
|
-
"drizzle"
|
|
38
|
-
],
|
|
39
|
-
"engines": {
|
|
40
|
-
"node": ">=18"
|
|
41
|
-
},
|
|
42
43
|
"publishConfig": {
|
|
43
44
|
"access": "public",
|
|
44
45
|
"provenance": true
|
|
@@ -47,15 +48,18 @@
|
|
|
47
48
|
"generate": "drizzle-kit generate",
|
|
48
49
|
"migrate": "bun src/migrate.ts",
|
|
49
50
|
"provision-roles": "bun src/provision-roles.ts",
|
|
50
|
-
"typecheck": "
|
|
51
|
+
"typecheck": "tsgo --noEmit",
|
|
51
52
|
"build": "tsup",
|
|
52
53
|
"prepublishOnly": "bash ../../scripts/prepublish-guard"
|
|
53
54
|
},
|
|
54
55
|
"dependencies": {
|
|
55
|
-
"@opengeni/codex": "^0.2.
|
|
56
|
-
"@opengeni/config": "^0.
|
|
57
|
-
"@opengeni/contracts": "^0.
|
|
56
|
+
"@opengeni/codex": "^0.2.2",
|
|
57
|
+
"@opengeni/config": "^0.5.0",
|
|
58
|
+
"@opengeni/contracts": "^0.10.0",
|
|
58
59
|
"drizzle-orm": "^0.45.2",
|
|
59
60
|
"postgres": "^3.4.7"
|
|
61
|
+
},
|
|
62
|
+
"engines": {
|
|
63
|
+
"node": ">=18"
|
|
60
64
|
}
|
|
61
65
|
}
|
|
@@ -12,14 +12,10 @@
|
|
|
12
12
|
// refresh helpers and the @opengeni/config key — keeping the refresh-CAS + RLS
|
|
13
13
|
// invariants co-located with the rows they protect.
|
|
14
14
|
//
|
|
15
|
-
// CROSS-PROCESS SAFETY
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// refreshes a token while a worker turn refreshes the same account, the loser's
|
|
20
|
-
// CAS writes 0 rows (stale version) and it re-reads the winner's token, so the
|
|
21
|
-
// one-time refresh token is never double-spent. RLS is untouched (every accessor
|
|
22
|
-
// wraps withWorkspaceRls internally).
|
|
15
|
+
// CROSS-PROCESS SAFETY: the process-module `inflight` map coalesces local callers,
|
|
16
|
+
// while a Postgres advisory transaction lock serializes API/worker replicas. A
|
|
17
|
+
// waiter re-reads after taking that lock and skips refresh when the version moved.
|
|
18
|
+
// The existing (id,version) CAS remains the final stale-family write fence.
|
|
23
19
|
|
|
24
20
|
import { environmentsEncryptionKeyBytes, type Settings } from "@opengeni/config";
|
|
25
21
|
import {
|
|
@@ -40,6 +36,7 @@ import {
|
|
|
40
36
|
recordCodexAccountUsage,
|
|
41
37
|
recordCodexTokenRefresh,
|
|
42
38
|
setCodexCredentialStatus,
|
|
39
|
+
withCodexCredentialRefreshLock,
|
|
43
40
|
type CodexCredentialForRun,
|
|
44
41
|
type Database,
|
|
45
42
|
} from "./index";
|
|
@@ -63,6 +60,7 @@ export type CodexAuthDeps = {
|
|
|
63
60
|
refresh: typeof refreshCodexToken;
|
|
64
61
|
encrypt: typeof encryptEnvironmentValue;
|
|
65
62
|
keyBytes: typeof environmentsEncryptionKeyBytes;
|
|
63
|
+
withRefreshLock: typeof withCodexCredentialRefreshLock;
|
|
66
64
|
};
|
|
67
65
|
|
|
68
66
|
const defaultDeps: CodexAuthDeps = {
|
|
@@ -72,6 +70,7 @@ const defaultDeps: CodexAuthDeps = {
|
|
|
72
70
|
refresh: refreshCodexToken,
|
|
73
71
|
encrypt: encryptEnvironmentValue,
|
|
74
72
|
keyBytes: environmentsEncryptionKeyBytes,
|
|
73
|
+
withRefreshLock: withCodexCredentialRefreshLock,
|
|
75
74
|
};
|
|
76
75
|
|
|
77
76
|
export function buildCodexTokenResolver(
|
|
@@ -92,7 +91,10 @@ export function buildCodexTokenResolver(
|
|
|
92
91
|
isFedramp: cred.isFedramp,
|
|
93
92
|
});
|
|
94
93
|
|
|
95
|
-
const performRefresh = async (
|
|
94
|
+
const performRefresh = async (
|
|
95
|
+
refreshDb: Database,
|
|
96
|
+
cred: CodexCredentialForRun,
|
|
97
|
+
): Promise<CodexTokenSnapshot> => {
|
|
96
98
|
try {
|
|
97
99
|
const next = await deps.refresh(cred.tokens.refreshToken);
|
|
98
100
|
const tokens = {
|
|
@@ -107,7 +109,7 @@ export function buildCodexTokenResolver(
|
|
|
107
109
|
// Compare-and-set on the loaded (id, version): if a disconnect→reconnect
|
|
108
110
|
// replaced the row mid-refresh, this writes 0 rows and we must NOT clobber
|
|
109
111
|
// the new credential with our now-defunct rotated tokens.
|
|
110
|
-
const persisted = await deps.recordRefresh(
|
|
112
|
+
const persisted = await deps.recordRefresh(refreshDb, {
|
|
111
113
|
id: cred.id,
|
|
112
114
|
version: cred.version,
|
|
113
115
|
workspaceId,
|
|
@@ -119,40 +121,73 @@ export function buildCodexTokenResolver(
|
|
|
119
121
|
// The row changed under us. Our rotated tokens belong to a stale family;
|
|
120
122
|
// fall back to whatever is connected NOW (a reconnect leaves an active
|
|
121
123
|
// row). If nothing active remains, a relogin is genuinely required.
|
|
122
|
-
const current = await deps.loadCredential(
|
|
124
|
+
const current = await deps.loadCredential(refreshDb, settings, workspaceId, credentialId);
|
|
123
125
|
if (current && current.status === "active") {
|
|
124
126
|
return snapshot(current);
|
|
125
127
|
}
|
|
126
|
-
throw new CodexReloginRequired(
|
|
128
|
+
throw new CodexReloginRequired(
|
|
129
|
+
"Codex credential changed during token refresh; reconnect required.",
|
|
130
|
+
);
|
|
127
131
|
}
|
|
128
|
-
return {
|
|
132
|
+
return {
|
|
133
|
+
accessToken: tokens.access_token,
|
|
134
|
+
chatgptAccountId: cred.chatgptAccountId,
|
|
135
|
+
isFedramp: cred.isFedramp,
|
|
136
|
+
};
|
|
129
137
|
} catch (error) {
|
|
130
138
|
if (error instanceof CodexReloginRequired) {
|
|
131
139
|
// Stamp needs_relogin ONLY if the row we refreshed is STILL current
|
|
132
140
|
// (compare-and-set on the loaded id+version). A relogin triggered by the
|
|
133
141
|
// OLD token family must never stamp needs_relogin onto a freshly
|
|
134
142
|
// reconnected credential.
|
|
135
|
-
await deps.setStatus(
|
|
143
|
+
await deps.setStatus(refreshDb, workspaceId, "needs_relogin", error.message, {
|
|
144
|
+
id: cred.id,
|
|
145
|
+
version: cred.version,
|
|
146
|
+
});
|
|
136
147
|
}
|
|
137
148
|
throw error;
|
|
138
149
|
}
|
|
139
150
|
};
|
|
140
151
|
|
|
141
|
-
// ALL refreshes — whether
|
|
142
|
-
//
|
|
143
|
-
// instance, so concurrent calls can never double-spend the one-time refresh
|
|
144
|
-
// token (which would trigger refresh_token_reused -> needs_relogin).
|
|
152
|
+
// ALL refreshes — whether proactive or a 401 retry — coalesce locally and then
|
|
153
|
+
// serialize globally before any rotating refresh token reaches the provider.
|
|
145
154
|
const doRefresh = (cred: CodexCredentialForRun): Promise<CodexTokenSnapshot> => {
|
|
146
155
|
const key = `${cred.id}:${cred.version}`;
|
|
147
156
|
const existing = inflight.get(key);
|
|
148
157
|
if (existing) {
|
|
149
158
|
return existing;
|
|
150
159
|
}
|
|
151
|
-
const promise =
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
160
|
+
const promise = deps
|
|
161
|
+
.withRefreshLock(db, workspaceId, credentialId, async (lockedDb) => {
|
|
162
|
+
try {
|
|
163
|
+
const current = await deps.loadCredential(lockedDb, settings, workspaceId, credentialId);
|
|
164
|
+
if (!current || current.status !== "active") {
|
|
165
|
+
throw new CodexReloginRequired(
|
|
166
|
+
"Codex credential became unavailable while waiting to refresh.",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (current.version !== cred.version) {
|
|
170
|
+
return { ok: true as const, value: snapshot(current) };
|
|
171
|
+
}
|
|
172
|
+
return { ok: true as const, value: await performRefresh(lockedDb, current) };
|
|
173
|
+
} catch (error) {
|
|
174
|
+
// withCodexCredentialRefreshLock uses an advisory TRANSACTION lock.
|
|
175
|
+
// performRefresh may persist `needs_relogin` before surfacing a
|
|
176
|
+
// permanent OAuth failure; throwing from this callback would roll that
|
|
177
|
+
// status write back with the outer transaction. Return the failure so
|
|
178
|
+
// the transaction commits, then rethrow after the lock is released.
|
|
179
|
+
return { ok: false as const, error };
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
.then((outcome) => {
|
|
183
|
+
if (!outcome.ok) throw outcome.error;
|
|
184
|
+
return outcome.value;
|
|
185
|
+
})
|
|
186
|
+
.finally(() => {
|
|
187
|
+
if (inflight.get(key) === promise) {
|
|
188
|
+
inflight.delete(key);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
156
191
|
inflight.set(key, promise);
|
|
157
192
|
return promise;
|
|
158
193
|
};
|