@opengeni/db 0.10.7 → 0.12.1

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 (35) hide show
  1. package/dist/{chunk-P6PKXY5W.js → chunk-VUKRIBO5.js} +485 -12
  2. package/dist/chunk-VUKRIBO5.js.map +1 -0
  3. package/dist/{chunk-KW526IJA.js → chunk-Y5WZZVQK.js} +80 -4
  4. package/dist/chunk-Y5WZZVQK.js.map +1 -0
  5. package/dist/index.d.ts +3 -2
  6. package/dist/index.js +5114 -2085
  7. package/dist/index.js.map +1 -1
  8. package/dist/migrate.d.ts +6 -3
  9. package/dist/migrate.js +1 -1
  10. package/dist/provision-roles.d.ts +720 -63
  11. package/dist/{schema-CqkzrBRS.d.ts → schema-BejThLcd.d.ts} +2965 -1194
  12. package/dist/schema.d.ts +1 -1
  13. package/dist/schema.js +17 -1
  14. package/drizzle/0109_nested_agent_depth_expand.sql +42 -0
  15. package/drizzle/0110_nested_agent_depth_boundary.sql +480 -0
  16. package/drizzle/0111_nested_agent_depth_backfill.sql +49 -0
  17. package/drizzle/0112_nested_agent_depth_contract.sql +38 -0
  18. package/drizzle/0113_nested_agent_depth_validate.sql +13 -0
  19. package/drizzle/0114_nested_agent_depth_contract.sql +49 -0
  20. package/drizzle/0115_nested_agent_depth_validate.sql +11 -0
  21. package/drizzle/0116_nested_agent_depth_index.sql +4 -0
  22. package/drizzle/0117_sandbox_recovery_generations.sql +699 -0
  23. package/drizzle/0118_new_session_drafts.sql +59 -0
  24. package/drizzle/0119_pending_tool_output_policy.sql +5 -0
  25. package/drizzle/0120_durable_goal_wake.sql +360 -0
  26. package/drizzle/0121_goal_update_idempotency.sql +11 -0
  27. package/package.json +3 -3
  28. package/src/index.ts +5961 -1240
  29. package/src/migrate.ts +131 -2
  30. package/src/new-session-drafts.ts +144 -0
  31. package/src/schema.ts +519 -15
  32. package/src/session-control.ts +42 -18
  33. package/src/session-tool-call-settlement.ts +6 -1
  34. package/dist/chunk-KW526IJA.js.map +0 -1
  35. package/dist/chunk-P6PKXY5W.js.map +0 -1
@@ -0,0 +1,699 @@
1
+ -- deployment-mode: maintenance
2
+ -- Activate the durable workspace-generation, mutation-admission, and
3
+ -- retained-process protocol. Old API/control/turn workers MUST be stopped: they
4
+ -- cannot name exact direct/process authority and do not set the v2 marker.
5
+
6
+ SET LOCAL lock_timeout = '5s';
7
+ SET LOCAL statement_timeout = '30min';
8
+
9
+ -- Reject an obviously live old application before waiting on table locks. The
10
+ -- same guard runs again after the locks to close the connect/write race.
11
+ DO $maintenance_preflight_guard$
12
+ BEGIN
13
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app')
14
+ AND EXISTS (
15
+ SELECT 1
16
+ FROM pg_stat_activity
17
+ WHERE datname = current_database()
18
+ AND usename = 'opengeni_app'
19
+ AND pid <> pg_backend_pid()
20
+ )
21
+ THEN
22
+ RAISE EXCEPTION
23
+ 'sandbox recovery protocol v2 activation requires all opengeni_app sessions to be stopped'
24
+ USING ERRCODE = '55000';
25
+ END IF;
26
+ END
27
+ $maintenance_preflight_guard$;
28
+
29
+ -- Serialize the one-way cutover against every old lease/holder/PTY writer. The
30
+ -- migration runner wraps this file in one transaction, so a timeout or guard
31
+ -- failure leaves no partially visible protocol.
32
+ LOCK TABLE sandbox_leases IN ACCESS EXCLUSIVE MODE;
33
+ LOCK TABLE sandbox_lease_holders IN ACCESS EXCLUSIVE MODE;
34
+ LOCK TABLE sandbox_pty_sessions IN ACCESS EXCLUSIVE MODE;
35
+
36
+ DO $maintenance_guard$
37
+ BEGIN
38
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app')
39
+ AND EXISTS (
40
+ SELECT 1
41
+ FROM pg_stat_activity
42
+ WHERE datname = current_database()
43
+ AND usename = 'opengeni_app'
44
+ AND pid <> pg_backend_pid()
45
+ )
46
+ THEN
47
+ RAISE EXCEPTION
48
+ 'sandbox recovery protocol v2 activation requires all opengeni_app sessions to be stopped'
49
+ USING ERRCODE = '55000';
50
+ END IF;
51
+ END
52
+ $maintenance_guard$;
53
+
54
+ -- One exact monotonic write set per lease. Generation zero is the legacy/clean
55
+ -- baseline; a verified archive is complete only when archive_generation equals
56
+ -- workspace_generation. Missing or older generations remain truthfully stale.
57
+ ALTER TABLE sandbox_leases
58
+ ADD COLUMN IF NOT EXISTS workspace_generation integer NOT NULL DEFAULT 0,
59
+ ADD COLUMN IF NOT EXISTS archive_generation integer;
60
+
61
+ ALTER TABLE sandbox_leases
62
+ DROP CONSTRAINT IF EXISTS sandbox_leases_workspace_generation_check,
63
+ DROP CONSTRAINT IF EXISTS sandbox_leases_archive_generation_check;
64
+ ALTER TABLE sandbox_leases
65
+ ADD CONSTRAINT sandbox_leases_workspace_generation_check
66
+ CHECK (workspace_generation >= 0),
67
+ ADD CONSTRAINT sandbox_leases_archive_generation_check
68
+ CHECK (
69
+ archive_generation IS NULL
70
+ OR (archive_generation >= 0 AND archive_generation <= workspace_generation)
71
+ );
72
+
73
+ -- Composite identities prevent a globally valid UUID from crossing tenant,
74
+ -- workspace, group, session, or lease scope through a malformed internal write.
75
+ CREATE UNIQUE INDEX IF NOT EXISTS sandbox_leases_scoped_id_uq
76
+ ON sandbox_leases (account_id, workspace_id, sandbox_group_id, id);
77
+ -- sandbox_leases' primary key already makes id unique; this narrower composite
78
+ -- exists solely as the referenced tenant/workspace key for holders.
79
+ CREATE UNIQUE INDEX IF NOT EXISTS sandbox_leases_account_workspace_id_uq
80
+ ON sandbox_leases (account_id, workspace_id, id);
81
+
82
+ ALTER TABLE sandbox_lease_holders
83
+ DROP CONSTRAINT IF EXISTS sandbox_lease_holders_kind_check;
84
+ ALTER TABLE sandbox_lease_holders
85
+ ADD CONSTRAINT sandbox_lease_holders_kind_check
86
+ CHECK (kind IN ('turn', 'viewer', 'direct', 'process'));
87
+
88
+ ALTER TABLE sandbox_lease_holders
89
+ DROP CONSTRAINT IF EXISTS sandbox_lease_holders_lease_scope_fk;
90
+ ALTER TABLE sandbox_lease_holders
91
+ ADD CONSTRAINT sandbox_lease_holders_lease_scope_fk
92
+ FOREIGN KEY (account_id, workspace_id, lease_id)
93
+ REFERENCES sandbox_leases (account_id, workspace_id, id)
94
+ ON DELETE CASCADE;
95
+
96
+ CREATE TABLE sandbox_workspace_mutation_admissions (
97
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
98
+ account_id uuid NOT NULL,
99
+ workspace_id uuid NOT NULL,
100
+ lease_id uuid NOT NULL,
101
+ sandbox_group_id uuid NOT NULL,
102
+ session_id uuid NOT NULL,
103
+ actor_kind text NOT NULL,
104
+ actor_id uuid NOT NULL,
105
+ turn_id uuid,
106
+ attempt_id uuid,
107
+ execution_generation integer,
108
+ holder_kind text NOT NULL,
109
+ holder_id text NOT NULL,
110
+ lease_epoch integer NOT NULL,
111
+ provider_backend text NOT NULL,
112
+ provider_instance_id text NOT NULL,
113
+ route_kind text NOT NULL,
114
+ route_target_id uuid,
115
+ route_epoch integer NOT NULL,
116
+ workspace_generation integer NOT NULL,
117
+ operation text NOT NULL,
118
+ provider_outcome text,
119
+ admitted_at timestamptz NOT NULL DEFAULT now(),
120
+ settled_at timestamptz,
121
+
122
+ CONSTRAINT sandbox_workspace_mutation_admissions_workspace_account_fk
123
+ FOREIGN KEY (workspace_id, account_id)
124
+ REFERENCES workspaces (id, account_id) ON DELETE CASCADE,
125
+ CONSTRAINT sandbox_workspace_mutation_admissions_workspace_session_fk
126
+ FOREIGN KEY (workspace_id, session_id)
127
+ REFERENCES sessions (workspace_id, id) ON DELETE RESTRICT,
128
+ CONSTRAINT sandbox_workspace_mutation_admissions_workspace_turn_fk
129
+ FOREIGN KEY (workspace_id, turn_id)
130
+ REFERENCES session_turns (workspace_id, id) ON DELETE RESTRICT,
131
+ CONSTRAINT sandbox_workspace_mutation_admissions_workspace_attempt_fk
132
+ FOREIGN KEY (workspace_id, attempt_id)
133
+ REFERENCES session_turn_attempts (workspace_id, id) ON DELETE RESTRICT,
134
+ CONSTRAINT sandbox_workspace_mutation_admissions_lease_scope_fk
135
+ FOREIGN KEY (account_id, workspace_id, sandbox_group_id, lease_id)
136
+ REFERENCES sandbox_leases (account_id, workspace_id, sandbox_group_id, id)
137
+ ON DELETE CASCADE,
138
+ CONSTRAINT sandbox_workspace_mutation_admissions_generation_check
139
+ CHECK (
140
+ workspace_generation > 0
141
+ AND lease_epoch >= 0
142
+ AND route_epoch >= 0
143
+ AND (execution_generation IS NULL OR execution_generation > 0)
144
+ ),
145
+ CONSTRAINT sandbox_workspace_mutation_admissions_actor_check
146
+ CHECK (
147
+ (
148
+ actor_kind = 'turn'
149
+ AND actor_id = attempt_id
150
+ AND turn_id IS NOT NULL
151
+ AND attempt_id IS NOT NULL
152
+ AND execution_generation IS NOT NULL
153
+ AND holder_kind = 'turn'
154
+ ) OR (
155
+ actor_kind = 'direct'
156
+ AND turn_id IS NULL
157
+ AND attempt_id IS NULL
158
+ AND execution_generation IS NULL
159
+ AND holder_kind = 'direct'
160
+ ) OR (
161
+ actor_kind = 'process'
162
+ AND turn_id IS NULL
163
+ AND attempt_id IS NULL
164
+ AND execution_generation IS NULL
165
+ AND holder_kind = 'process'
166
+ )
167
+ ),
168
+ CONSTRAINT sandbox_workspace_mutation_admissions_route_check
169
+ CHECK (
170
+ actor_kind IN ('turn', 'direct', 'process')
171
+ AND holder_kind IN ('turn', 'direct', 'process')
172
+ AND octet_length(holder_id) BETWEEN 1 AND 256
173
+ AND octet_length(provider_backend) BETWEEN 1 AND 64
174
+ AND octet_length(provider_instance_id) BETWEEN 1 AND 512
175
+ AND route_kind IN ('home', 'active')
176
+ AND (route_kind = 'active' OR route_target_id IS NULL)
177
+ ),
178
+ CONSTRAINT sandbox_workspace_mutation_admissions_operation_check
179
+ CHECK (octet_length(operation) BETWEEN 1 AND 128),
180
+ CONSTRAINT sandbox_workspace_mutation_admissions_outcome_check
181
+ CHECK (provider_outcome IS NULL OR provider_outcome IN ('resolved', 'rejected', 'retained')),
182
+ CONSTRAINT sandbox_workspace_mutation_admissions_settlement_check
183
+ CHECK (
184
+ (provider_outcome IS NULL AND settled_at IS NULL)
185
+ OR (provider_outcome = 'retained' AND settled_at IS NULL)
186
+ OR (provider_outcome IN ('resolved', 'rejected') AND settled_at IS NOT NULL)
187
+ )
188
+ );
189
+
190
+ CREATE UNIQUE INDEX sandbox_workspace_mutation_admissions_lease_generation_uq
191
+ ON sandbox_workspace_mutation_admissions (lease_id, workspace_generation);
192
+ CREATE UNIQUE INDEX sandbox_workspace_mutation_admissions_scoped_id_uq
193
+ ON sandbox_workspace_mutation_admissions
194
+ (account_id, workspace_id, session_id, lease_id, id);
195
+ CREATE INDEX sandbox_workspace_mutation_admissions_blocking_idx
196
+ ON sandbox_workspace_mutation_admissions (lease_id, workspace_generation)
197
+ WHERE settled_at IS NULL;
198
+ CREATE INDEX sandbox_workspace_mutation_admissions_attempt_idx
199
+ ON sandbox_workspace_mutation_admissions (workspace_id, attempt_id);
200
+ CREATE INDEX sandbox_workspace_mutation_admissions_actor_idx
201
+ ON sandbox_workspace_mutation_admissions (workspace_id, actor_kind, actor_id);
202
+
203
+ CREATE TABLE sandbox_retained_processes (
204
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
205
+ account_id uuid NOT NULL,
206
+ workspace_id uuid NOT NULL,
207
+ session_id uuid NOT NULL,
208
+ lease_id uuid NOT NULL,
209
+ sandbox_group_id uuid NOT NULL,
210
+ parent_admission_id uuid NOT NULL,
211
+ holder_id text NOT NULL,
212
+ owner_actor_kind text NOT NULL,
213
+ owner_actor_id uuid NOT NULL,
214
+ owner_turn_id uuid,
215
+ owner_attempt_id uuid,
216
+ owner_execution_generation integer,
217
+ lease_epoch integer NOT NULL,
218
+ provider_backend text NOT NULL,
219
+ provider_instance_id text NOT NULL,
220
+ route_kind text NOT NULL,
221
+ route_target_id uuid,
222
+ route_epoch integer NOT NULL,
223
+ provider_session_id integer NOT NULL,
224
+ state text NOT NULL DEFAULT 'active',
225
+ exit_code integer,
226
+ settlement_reason text,
227
+ started_at timestamptz NOT NULL DEFAULT now(),
228
+ settled_at timestamptz,
229
+
230
+ CONSTRAINT sandbox_retained_processes_workspace_account_fk
231
+ FOREIGN KEY (workspace_id, account_id)
232
+ REFERENCES workspaces (id, account_id) ON DELETE CASCADE,
233
+ CONSTRAINT sandbox_retained_processes_workspace_session_fk
234
+ FOREIGN KEY (workspace_id, session_id)
235
+ REFERENCES sessions (workspace_id, id) ON DELETE RESTRICT,
236
+ CONSTRAINT sandbox_retained_processes_parent_admission_scope_fk
237
+ FOREIGN KEY (account_id, workspace_id, session_id, lease_id, parent_admission_id)
238
+ REFERENCES sandbox_workspace_mutation_admissions
239
+ (account_id, workspace_id, session_id, lease_id, id)
240
+ ON DELETE RESTRICT,
241
+ CONSTRAINT sandbox_retained_processes_identity_check
242
+ CHECK (
243
+ lease_epoch >= 0
244
+ AND route_epoch >= 0
245
+ AND provider_session_id > 0
246
+ AND octet_length(holder_id) BETWEEN 1 AND 256
247
+ AND octet_length(provider_backend) BETWEEN 1 AND 64
248
+ AND octet_length(provider_instance_id) BETWEEN 1 AND 512
249
+ AND route_kind IN ('home', 'active')
250
+ AND (route_kind = 'active' OR route_target_id IS NULL)
251
+ ),
252
+ CONSTRAINT sandbox_retained_processes_owner_check
253
+ CHECK (
254
+ (
255
+ owner_actor_kind = 'turn'
256
+ AND owner_actor_id = owner_attempt_id
257
+ AND owner_turn_id IS NOT NULL
258
+ AND owner_attempt_id IS NOT NULL
259
+ AND owner_execution_generation > 0
260
+ ) OR (
261
+ owner_actor_kind = 'direct'
262
+ AND owner_turn_id IS NULL
263
+ AND owner_attempt_id IS NULL
264
+ AND owner_execution_generation IS NULL
265
+ )
266
+ ),
267
+ CONSTRAINT sandbox_retained_processes_settlement_check
268
+ CHECK (
269
+ (state = 'active' AND settled_at IS NULL AND exit_code IS NULL)
270
+ OR (state = 'exited' AND settled_at IS NOT NULL)
271
+ OR (state = 'lost' AND settled_at IS NOT NULL AND exit_code IS NULL)
272
+ ),
273
+ CONSTRAINT sandbox_retained_processes_reason_check
274
+ CHECK (settlement_reason IS NULL OR octet_length(settlement_reason) BETWEEN 1 AND 512)
275
+ );
276
+
277
+ CREATE UNIQUE INDEX sandbox_retained_processes_scoped_id_uq
278
+ ON sandbox_retained_processes (account_id, workspace_id, session_id, lease_id, id);
279
+ CREATE UNIQUE INDEX sandbox_retained_processes_parent_admission_uq
280
+ ON sandbox_retained_processes (parent_admission_id);
281
+ CREATE UNIQUE INDEX sandbox_retained_processes_holder_uq
282
+ ON sandbox_retained_processes (lease_id, holder_id);
283
+ CREATE UNIQUE INDEX sandbox_retained_processes_live_provider_session_uq
284
+ ON sandbox_retained_processes
285
+ (lease_id, lease_epoch, provider_instance_id, route_epoch, provider_session_id)
286
+ WHERE state = 'active';
287
+ CREATE INDEX sandbox_retained_processes_active_idx
288
+ ON sandbox_retained_processes (workspace_id, session_id, started_at)
289
+ WHERE state = 'active';
290
+
291
+ -- A retained row is the durable continuation of exactly one parent admission.
292
+ -- This deferred check observes the final state of atomic promotion/settlement:
293
+ -- active => retained parent + exact non-TTL holder; terminal => settled parent
294
+ -- + holder removed. It never treats a numeric provider session id as authority.
295
+ CREATE OR REPLACE FUNCTION opengeni_private.validate_sandbox_retained_process_v2()
296
+ RETURNS trigger
297
+ LANGUAGE plpgsql
298
+ AS $$
299
+ BEGIN
300
+ IF NOT EXISTS (
301
+ SELECT 1
302
+ FROM sandbox_workspace_mutation_admissions admission
303
+ WHERE admission.id = NEW.parent_admission_id
304
+ AND admission.account_id = NEW.account_id
305
+ AND admission.workspace_id = NEW.workspace_id
306
+ AND admission.session_id = NEW.session_id
307
+ AND admission.lease_id = NEW.lease_id
308
+ AND admission.sandbox_group_id = NEW.sandbox_group_id
309
+ AND admission.actor_kind = NEW.owner_actor_kind
310
+ AND admission.actor_id = NEW.owner_actor_id
311
+ AND admission.turn_id IS NOT DISTINCT FROM NEW.owner_turn_id
312
+ AND admission.attempt_id IS NOT DISTINCT FROM NEW.owner_attempt_id
313
+ AND admission.execution_generation IS NOT DISTINCT FROM NEW.owner_execution_generation
314
+ AND admission.lease_epoch = NEW.lease_epoch
315
+ AND admission.provider_backend = NEW.provider_backend
316
+ AND admission.provider_instance_id = NEW.provider_instance_id
317
+ AND admission.route_kind = NEW.route_kind
318
+ AND admission.route_target_id IS NOT DISTINCT FROM NEW.route_target_id
319
+ AND admission.route_epoch = NEW.route_epoch
320
+ AND (
321
+ (
322
+ NEW.state = 'active'
323
+ AND admission.provider_outcome = 'retained'
324
+ AND admission.settled_at IS NULL
325
+ AND EXISTS (
326
+ SELECT 1 FROM sandbox_lease_holders holder
327
+ WHERE holder.lease_id = NEW.lease_id
328
+ AND holder.account_id = NEW.account_id
329
+ AND holder.workspace_id = NEW.workspace_id
330
+ AND holder.kind = 'process'
331
+ AND holder.holder_id = NEW.holder_id
332
+ AND holder.subject_id = NEW.session_id
333
+ )
334
+ ) OR (
335
+ NEW.state IN ('exited', 'lost')
336
+ AND admission.provider_outcome IN ('resolved', 'rejected')
337
+ AND admission.settled_at IS NOT NULL
338
+ AND NOT EXISTS (
339
+ SELECT 1 FROM sandbox_lease_holders holder
340
+ WHERE holder.lease_id = NEW.lease_id
341
+ AND holder.kind = 'process'
342
+ AND holder.holder_id = NEW.holder_id
343
+ )
344
+ AND NOT EXISTS (
345
+ SELECT 1 FROM sandbox_pty_sessions pty
346
+ WHERE pty.retained_process_id = NEW.id
347
+ AND pty.status = 'open'
348
+ )
349
+ )
350
+ )
351
+ ) THEN
352
+ RAISE EXCEPTION 'retained process does not match its parent admission and holder state'
353
+ USING ERRCODE = '55000';
354
+ END IF;
355
+ RETURN NEW;
356
+ END;
357
+ $$;
358
+
359
+ CREATE CONSTRAINT TRIGGER sandbox_retained_processes_identity_v2
360
+ AFTER INSERT OR UPDATE ON sandbox_retained_processes
361
+ DEFERRABLE INITIALLY DEFERRED
362
+ FOR EACH ROW EXECUTE FUNCTION opengeni_private.validate_sandbox_retained_process_v2();
363
+
364
+ -- Numeric-only PTYs cannot be made authoritative retroactively. Maintenance
365
+ -- closes them before the new open-row constraint is installed; callers will
366
+ -- observe terminal metadata rather than risking stdin against a reused id.
367
+ UPDATE sandbox_pty_sessions
368
+ SET status = 'closed', closed_at = coalesce(closed_at, clock_timestamp())
369
+ WHERE status = 'open';
370
+
371
+ ALTER TABLE sandbox_pty_sessions
372
+ ADD COLUMN IF NOT EXISTS lease_id uuid,
373
+ ADD COLUMN IF NOT EXISTS sandbox_group_id uuid,
374
+ ADD COLUMN IF NOT EXISTS retained_process_id uuid,
375
+ ADD COLUMN IF NOT EXISTS open_admission_id uuid,
376
+ ADD COLUMN IF NOT EXISTS provider_backend text,
377
+ ADD COLUMN IF NOT EXISTS provider_instance_id text,
378
+ ADD COLUMN IF NOT EXISTS route_kind text,
379
+ ADD COLUMN IF NOT EXISTS route_target_id uuid,
380
+ ADD COLUMN IF NOT EXISTS route_epoch integer;
381
+
382
+ ALTER TABLE sandbox_pty_sessions
383
+ DROP CONSTRAINT IF EXISTS sandbox_pty_sessions_open_identity_check;
384
+ ALTER TABLE sandbox_pty_sessions
385
+ ADD CONSTRAINT sandbox_pty_sessions_open_identity_check
386
+ CHECK (
387
+ status <> 'open' OR (
388
+ lease_id IS NOT NULL
389
+ AND sandbox_group_id IS NOT NULL
390
+ AND retained_process_id IS NOT NULL
391
+ AND open_admission_id IS NOT NULL
392
+ AND exec_session_id > 0
393
+ AND lease_epoch >= 0
394
+ AND octet_length(provider_backend) BETWEEN 1 AND 64
395
+ AND octet_length(provider_instance_id) BETWEEN 1 AND 512
396
+ AND route_kind IN ('home', 'active')
397
+ AND (route_kind = 'active' OR route_target_id IS NULL)
398
+ AND route_epoch >= 0
399
+ )
400
+ );
401
+
402
+ ALTER TABLE sandbox_pty_sessions
403
+ ADD CONSTRAINT sandbox_pty_sessions_retained_process_scope_fk
404
+ FOREIGN KEY (account_id, workspace_id, session_id, lease_id, retained_process_id)
405
+ REFERENCES sandbox_retained_processes
406
+ (account_id, workspace_id, session_id, lease_id, id)
407
+ ON DELETE RESTRICT;
408
+
409
+ CREATE UNIQUE INDEX sandbox_pty_sessions_open_process_uq
410
+ ON sandbox_pty_sessions (retained_process_id)
411
+ WHERE status = 'open';
412
+
413
+ -- Cross-table semantics that cannot be expressed as a CHECK: an open PTY must
414
+ -- copy the exact active retained process, including its parent admission and
415
+ -- pinned provider/route identity. Closed legacy rows remain readable evidence.
416
+ CREATE OR REPLACE FUNCTION opengeni_private.validate_sandbox_pty_process_v2()
417
+ RETURNS trigger
418
+ LANGUAGE plpgsql
419
+ AS $$
420
+ BEGIN
421
+ IF NEW.status = 'open' AND NOT EXISTS (
422
+ SELECT 1
423
+ FROM sandbox_retained_processes process
424
+ WHERE process.id = NEW.retained_process_id
425
+ AND process.account_id = NEW.account_id
426
+ AND process.workspace_id = NEW.workspace_id
427
+ AND process.session_id = NEW.session_id
428
+ AND process.lease_id = NEW.lease_id
429
+ AND process.sandbox_group_id = NEW.sandbox_group_id
430
+ AND process.parent_admission_id = NEW.open_admission_id
431
+ AND process.lease_epoch = NEW.lease_epoch
432
+ AND process.provider_backend = NEW.provider_backend
433
+ AND process.provider_instance_id = NEW.provider_instance_id
434
+ AND process.route_kind = NEW.route_kind
435
+ AND process.route_target_id IS NOT DISTINCT FROM NEW.route_target_id
436
+ AND process.route_epoch = NEW.route_epoch
437
+ AND process.provider_session_id = NEW.exec_session_id
438
+ AND process.state = 'active'
439
+ ) THEN
440
+ RAISE EXCEPTION 'open PTY does not match its exact retained process identity'
441
+ USING ERRCODE = '55000';
442
+ END IF;
443
+ RETURN NEW;
444
+ END;
445
+ $$;
446
+
447
+ CREATE CONSTRAINT TRIGGER sandbox_pty_sessions_process_identity_v2
448
+ AFTER INSERT OR UPDATE ON sandbox_pty_sessions
449
+ DEFERRABLE INITIALLY DEFERRED
450
+ FOR EACH ROW EXECUTE FUNCTION opengeni_private.validate_sandbox_pty_process_v2();
451
+
452
+ -- Preserve archive truth when 0108's warming owner expires. The helper strips
453
+ -- live provider/rematerialization identity and publishes pending only when the
454
+ -- archived generation exactly covers the closed workspace generation.
455
+ CREATE OR REPLACE FUNCTION opengeni_private.warming_reset_resume_state_v2(
456
+ p_backend text,
457
+ p_resume_backend_id text,
458
+ p_resume_state jsonb,
459
+ p_workspace_generation integer,
460
+ p_archive_generation integer,
461
+ p_reset_at timestamptz
462
+ )
463
+ RETURNS jsonb
464
+ LANGUAGE sql
465
+ AS $$
466
+ SELECT CASE
467
+ WHEN coalesce(p_resume_state #>> '{sessionState,workspaceArchive}', '') <> ''
468
+ OR coalesce(p_resume_state #>> '{sessionState,workspaceArchivePrev}', '') <> ''
469
+ THEN jsonb_strip_nulls(jsonb_build_object(
470
+ 'backendId', coalesce(p_resume_state ->> 'backendId', p_resume_backend_id, p_backend),
471
+ 'sessionState', jsonb_strip_nulls(jsonb_build_object(
472
+ 'workspaceArchive', p_resume_state #> '{sessionState,workspaceArchive}',
473
+ 'workspaceArchiveMeta', p_resume_state #> '{sessionState,workspaceArchiveMeta}',
474
+ 'workspaceArchivePrev', p_resume_state #> '{sessionState,workspaceArchivePrev}',
475
+ 'workspaceArchivePrevMeta', p_resume_state #> '{sessionState,workspaceArchivePrevMeta}',
476
+ 'workspaceArchiveAt', p_resume_state #> '{sessionState,workspaceArchiveAt}'
477
+ )),
478
+ 'opengeniRecovery', jsonb_build_object(
479
+ 'provider', jsonb_build_object(
480
+ 'status', 'not_created', 'instanceId', null, 'observedAt', to_jsonb(p_reset_at)
481
+ ),
482
+ 'archive', coalesce(
483
+ p_resume_state #> '{opengeniRecovery,archive}',
484
+ jsonb_build_object(
485
+ 'status', CASE
486
+ WHEN coalesce(p_resume_state #>> '{sessionState,workspaceArchive}', '') <> ''
487
+ AND p_resume_state #> '{sessionState,workspaceArchiveMeta}' IS NOT NULL
488
+ THEN 'available' ELSE 'unverified' END,
489
+ 'current', p_resume_state #> '{sessionState,workspaceArchiveMeta}',
490
+ 'previous', p_resume_state #> '{sessionState,workspaceArchivePrevMeta}'
491
+ )
492
+ ),
493
+ 'restore', jsonb_strip_nulls(jsonb_build_object(
494
+ 'status', CASE
495
+ WHEN p_archive_generation = p_workspace_generation
496
+ AND coalesce(p_resume_state #>> '{sessionState,workspaceArchive}', '') <> ''
497
+ AND p_resume_state #> '{sessionState,workspaceArchiveMeta}' IS NOT NULL
498
+ THEN 'pending' ELSE 'degraded' END,
499
+ 'rematerializationId', null,
500
+ 'selectedRevision', coalesce(
501
+ p_resume_state #>> '{opengeniRecovery,archive,current,revision}',
502
+ p_resume_state #>> '{sessionState,workspaceArchiveMeta,revision}'
503
+ ),
504
+ 'startedAt', null,
505
+ 'completedAt', to_jsonb(p_reset_at),
506
+ 'failureCode', CASE
507
+ WHEN p_archive_generation = p_workspace_generation
508
+ AND coalesce(p_resume_state #>> '{sessionState,workspaceArchive}', '') <> ''
509
+ AND p_resume_state #> '{sessionState,workspaceArchiveMeta}' IS NOT NULL
510
+ THEN null ELSE 'archive_generation_mismatch' END,
511
+ 'retryable', CASE
512
+ WHEN p_archive_generation = p_workspace_generation
513
+ AND coalesce(p_resume_state #>> '{sessionState,workspaceArchive}', '') <> ''
514
+ AND p_resume_state #> '{sessionState,workspaceArchiveMeta}' IS NOT NULL
515
+ THEN null ELSE to_jsonb(false) END
516
+ )),
517
+ 'workspace', jsonb_build_object(
518
+ 'status', CASE
519
+ WHEN p_archive_generation = p_workspace_generation
520
+ AND coalesce(p_resume_state #>> '{sessionState,workspaceArchive}', '') <> ''
521
+ AND p_resume_state #> '{sessionState,workspaceArchiveMeta}' IS NOT NULL
522
+ THEN 'not_ready' ELSE 'degraded' END,
523
+ 'verifiedRevision', null, 'verifiedAt', null
524
+ )
525
+ )
526
+ ))
527
+ ELSE null
528
+ END;
529
+ $$;
530
+
531
+ -- Replace 0108 in place. Direct holders share the bounded request TTL with
532
+ -- viewers. Process holders are deliberately absent from every TTL delete and
533
+ -- can leave only through exact retained-process settlement.
534
+ CREATE OR REPLACE FUNCTION opengeni_private.reap_sandbox_leases(
535
+ p_viewer_holder_ttl_ms bigint,
536
+ p_turn_holder_ttl_ms bigint,
537
+ p_idle_grace_ms bigint
538
+ )
539
+ RETURNS TABLE (workspace_id uuid, sandbox_group_id uuid, instance_id text, lease_epoch integer)
540
+ LANGUAGE plpgsql
541
+ SECURITY DEFINER
542
+ AS $$
543
+ BEGIN
544
+ PERFORM set_config('opengeni.sandbox_recovery_protocol_v2', '1', true);
545
+
546
+ DELETE FROM sandbox_lease_holders holder
547
+ WHERE holder.kind IN ('viewer', 'direct')
548
+ AND holder.last_heartbeat_at
549
+ < now() - make_interval(secs => p_viewer_holder_ttl_ms / 1000.0);
550
+
551
+ IF p_turn_holder_ttl_ms > 0 THEN
552
+ DELETE FROM sandbox_lease_holders holder
553
+ WHERE holder.kind = 'turn'
554
+ AND holder.last_heartbeat_at
555
+ < now() - make_interval(secs => p_turn_holder_ttl_ms / 1000.0);
556
+ END IF;
557
+
558
+ UPDATE sandbox_leases lease SET
559
+ refcount = counts.total,
560
+ turn_holders = counts.turns,
561
+ viewer_holders = counts.viewers,
562
+ liveness = CASE
563
+ WHEN lease.liveness = 'warm' AND counts.total = 0 AND counts.turns = 0
564
+ THEN 'draining' ELSE lease.liveness END,
565
+ expires_at = CASE
566
+ WHEN lease.liveness = 'warm' AND counts.total = 0 AND counts.turns = 0
567
+ THEN now() + make_interval(secs => p_idle_grace_ms / 1000.0)
568
+ ELSE lease.expires_at END,
569
+ updated_at = now()
570
+ FROM (
571
+ SELECT candidate.id,
572
+ (SELECT count(*) FROM sandbox_lease_holders holder
573
+ WHERE holder.lease_id = candidate.id)::int AS total,
574
+ (SELECT count(*) FROM sandbox_lease_holders holder
575
+ WHERE holder.lease_id = candidate.id AND holder.kind = 'turn')::int AS turns,
576
+ (SELECT count(*) FROM sandbox_lease_holders holder
577
+ WHERE holder.lease_id = candidate.id AND holder.kind = 'viewer')::int AS viewers
578
+ FROM sandbox_leases candidate
579
+ ) counts
580
+ WHERE lease.id = counts.id;
581
+
582
+ UPDATE sandbox_leases lease SET
583
+ liveness = 'cold',
584
+ instance_id = null,
585
+ lease_epoch = lease.lease_epoch + 1,
586
+ resume_state = opengeni_private.warming_reset_resume_state_v2(
587
+ lease.backend, lease.resume_backend_id, lease.resume_state,
588
+ lease.workspace_generation, lease.archive_generation, clock_timestamp()
589
+ ),
590
+ resume_backend_id = CASE
591
+ WHEN coalesce(lease.resume_state #>> '{sessionState,workspaceArchive}', '') <> ''
592
+ OR coalesce(lease.resume_state #>> '{sessionState,workspaceArchivePrev}', '') <> ''
593
+ THEN coalesce(lease.resume_backend_id, lease.backend)
594
+ ELSE null END,
595
+ data_plane_url = null,
596
+ terminal_data_plane_url = null,
597
+ updated_at = now()
598
+ WHERE lease.liveness = 'warming'
599
+ AND lease.expires_at < now()
600
+ AND lease.instance_id IS NULL;
601
+
602
+ UPDATE sandbox_leases lease SET
603
+ liveness = 'draining',
604
+ refcount = 0,
605
+ turn_holders = 0,
606
+ viewer_holders = 0,
607
+ data_plane_url = null,
608
+ terminal_data_plane_url = null,
609
+ lease_epoch = lease.lease_epoch + 1,
610
+ expires_at = now() - interval '1 millisecond',
611
+ updated_at = now()
612
+ WHERE lease.liveness = 'warming'
613
+ AND lease.expires_at < now()
614
+ AND lease.instance_id IS NOT NULL;
615
+
616
+ RETURN QUERY
617
+ SELECT lease.workspace_id, lease.sandbox_group_id,
618
+ lease.instance_id, lease.lease_epoch
619
+ FROM sandbox_leases lease
620
+ WHERE lease.liveness = 'draining'
621
+ AND lease.expires_at < now()
622
+ AND lease.refcount = 0;
623
+ END;
624
+ $$;
625
+
626
+ -- No mixed old/new writer interval: after maintenance activation every lease,
627
+ -- holder, admission, retained-process, and PTY mutation by a non-superuser must
628
+ -- explicitly opt into v2 in its current transaction.
629
+ CREATE OR REPLACE FUNCTION opengeni_private.enforce_sandbox_recovery_protocol_v2()
630
+ RETURNS trigger
631
+ LANGUAGE plpgsql
632
+ AS $$
633
+ BEGIN
634
+ IF NOT EXISTS (
635
+ SELECT 1 FROM pg_roles WHERE rolname = session_user AND rolsuper
636
+ ) AND current_setting('opengeni.sandbox_recovery_protocol_v2', true) IS DISTINCT FROM '1' THEN
637
+ RAISE EXCEPTION 'sandbox recovery protocol v2 marker is required for % on %', TG_OP, TG_TABLE_NAME
638
+ USING ERRCODE = '55000';
639
+ END IF;
640
+ IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
641
+ RETURN NEW;
642
+ END;
643
+ $$;
644
+
645
+ CREATE TRIGGER sandbox_recovery_protocol_v2_lease_guard
646
+ BEFORE INSERT OR UPDATE OR DELETE ON sandbox_leases
647
+ FOR EACH ROW EXECUTE FUNCTION opengeni_private.enforce_sandbox_recovery_protocol_v2();
648
+ CREATE TRIGGER sandbox_recovery_protocol_v2_holder_guard
649
+ BEFORE INSERT OR UPDATE OR DELETE ON sandbox_lease_holders
650
+ FOR EACH ROW EXECUTE FUNCTION opengeni_private.enforce_sandbox_recovery_protocol_v2();
651
+ CREATE TRIGGER sandbox_recovery_protocol_v2_admission_guard
652
+ BEFORE INSERT OR UPDATE OR DELETE ON sandbox_workspace_mutation_admissions
653
+ FOR EACH ROW EXECUTE FUNCTION opengeni_private.enforce_sandbox_recovery_protocol_v2();
654
+ CREATE TRIGGER sandbox_recovery_protocol_v2_process_guard
655
+ BEFORE INSERT OR UPDATE OR DELETE ON sandbox_retained_processes
656
+ FOR EACH ROW EXECUTE FUNCTION opengeni_private.enforce_sandbox_recovery_protocol_v2();
657
+ CREATE TRIGGER sandbox_recovery_protocol_v2_pty_guard
658
+ BEFORE INSERT OR UPDATE OR DELETE ON sandbox_pty_sessions
659
+ FOR EACH ROW EXECUTE FUNCTION opengeni_private.enforce_sandbox_recovery_protocol_v2();
660
+
661
+ ALTER TABLE sandbox_workspace_mutation_admissions ENABLE ROW LEVEL SECURITY;
662
+ ALTER TABLE sandbox_workspace_mutation_admissions FORCE ROW LEVEL SECURITY;
663
+ ALTER TABLE sandbox_retained_processes ENABLE ROW LEVEL SECURITY;
664
+ ALTER TABLE sandbox_retained_processes FORCE ROW LEVEL SECURITY;
665
+
666
+ CREATE POLICY workspace_isolation ON sandbox_workspace_mutation_admissions
667
+ USING (opengeni_private.workspace_rls_visible(account_id, workspace_id))
668
+ WITH CHECK (opengeni_private.workspace_rls_visible(account_id, workspace_id));
669
+ CREATE POLICY workspace_isolation ON sandbox_retained_processes
670
+ USING (opengeni_private.workspace_rls_visible(account_id, workspace_id))
671
+ WITH CHECK (opengeni_private.workspace_rls_visible(account_id, workspace_id));
672
+
673
+ DO $grants$
674
+ DECLARE data_schema text := current_schema();
675
+ BEGIN
676
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
677
+ EXECUTE format(
678
+ 'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE %I.sandbox_workspace_mutation_admissions TO opengeni_app',
679
+ data_schema
680
+ );
681
+ EXECUTE format(
682
+ 'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE %I.sandbox_retained_processes TO opengeni_app',
683
+ data_schema
684
+ );
685
+ EXECUTE format(
686
+ 'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE %I.sandbox_leases, %I.sandbox_lease_holders, %I.sandbox_pty_sessions TO opengeni_app',
687
+ data_schema, data_schema, data_schema
688
+ );
689
+ GRANT EXECUTE ON FUNCTION opengeni_private.reap_sandbox_leases(bigint, bigint, bigint)
690
+ TO opengeni_app;
691
+ GRANT EXECUTE ON FUNCTION opengeni_private.enforce_sandbox_recovery_protocol_v2()
692
+ TO opengeni_app;
693
+ GRANT EXECUTE ON FUNCTION opengeni_private.validate_sandbox_pty_process_v2()
694
+ TO opengeni_app;
695
+ GRANT EXECUTE ON FUNCTION opengeni_private.validate_sandbox_retained_process_v2()
696
+ TO opengeni_app;
697
+ END IF;
698
+ END
699
+ $grants$;