pilotswarm-sdk 0.5.13 → 0.5.14

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 (103) hide show
  1. package/api/src/api-client.js +11 -7
  2. package/api/src/http-api-transport.js +29 -0
  3. package/api/src/protocol.js +121 -94
  4. package/dist/client.d.ts +7 -1
  5. package/dist/client.d.ts.map +1 -1
  6. package/dist/client.js +7 -0
  7. package/dist/client.js.map +1 -1
  8. package/dist/cms-migrations.d.ts.map +1 -1
  9. package/dist/cms-migrations.js +1129 -0
  10. package/dist/cms-migrations.js.map +1 -1
  11. package/dist/cms.d.ts +151 -0
  12. package/dist/cms.d.ts.map +1 -1
  13. package/dist/cms.js +125 -4
  14. package/dist/cms.js.map +1 -1
  15. package/dist/management-client.d.ts +74 -2
  16. package/dist/management-client.d.ts.map +1 -1
  17. package/dist/management-client.js +70 -2
  18. package/dist/management-client.js.map +1 -1
  19. package/dist/message-sender.d.ts +38 -0
  20. package/dist/message-sender.d.ts.map +1 -0
  21. package/dist/message-sender.js +63 -0
  22. package/dist/message-sender.js.map +1 -0
  23. package/dist/orchestration/index.d.ts +2 -2
  24. package/dist/orchestration/index.js +1 -1
  25. package/dist/orchestration/lifecycle.d.ts.map +1 -1
  26. package/dist/orchestration/lifecycle.js +8 -0
  27. package/dist/orchestration/lifecycle.js.map +1 -1
  28. package/dist/orchestration/queue.d.ts.map +1 -1
  29. package/dist/orchestration/queue.js +27 -8
  30. package/dist/orchestration/queue.js.map +1 -1
  31. package/dist/orchestration/runtime.d.ts +1 -1
  32. package/dist/orchestration/state.d.ts +7 -0
  33. package/dist/orchestration/state.d.ts.map +1 -1
  34. package/dist/orchestration/state.js +7 -0
  35. package/dist/orchestration/state.js.map +1 -1
  36. package/dist/orchestration/turn.d.ts +2 -1
  37. package/dist/orchestration/turn.d.ts.map +1 -1
  38. package/dist/orchestration/turn.js +2 -1
  39. package/dist/orchestration/turn.js.map +1 -1
  40. package/dist/orchestration/utils.d.ts +17 -0
  41. package/dist/orchestration/utils.d.ts.map +1 -1
  42. package/dist/orchestration/utils.js +90 -0
  43. package/dist/orchestration/utils.js.map +1 -1
  44. package/dist/orchestration-registry.d.ts.map +1 -1
  45. package/dist/orchestration-registry.js +4 -2
  46. package/dist/orchestration-registry.js.map +1 -1
  47. package/dist/orchestration-version.d.ts +1 -1
  48. package/dist/orchestration-version.js +1 -1
  49. package/dist/orchestration.d.ts +2 -2
  50. package/dist/orchestration.js +1 -1
  51. package/dist/orchestration_1_0_46.d.ts +1 -1
  52. package/dist/orchestration_1_0_47.d.ts +1 -1
  53. package/dist/orchestration_1_0_48.d.ts +1 -1
  54. package/dist/orchestration_1_0_49.d.ts +1 -1
  55. package/dist/orchestration_1_0_50.d.ts +1 -1
  56. package/dist/orchestration_1_0_60/agents.d.ts +41 -0
  57. package/dist/orchestration_1_0_60/agents.d.ts.map +1 -0
  58. package/dist/orchestration_1_0_60/agents.js +773 -0
  59. package/dist/orchestration_1_0_60/agents.js.map +1 -0
  60. package/dist/orchestration_1_0_60/index.d.ts +24 -0
  61. package/dist/orchestration_1_0_60/index.d.ts.map +1 -0
  62. package/dist/orchestration_1_0_60/index.js +13 -0
  63. package/dist/orchestration_1_0_60/index.js.map +1 -0
  64. package/dist/orchestration_1_0_60/lifecycle.d.ts +48 -0
  65. package/dist/orchestration_1_0_60/lifecycle.d.ts.map +1 -0
  66. package/dist/orchestration_1_0_60/lifecycle.js +557 -0
  67. package/dist/orchestration_1_0_60/lifecycle.js.map +1 -0
  68. package/dist/orchestration_1_0_60/queue.d.ts +7 -0
  69. package/dist/orchestration_1_0_60/queue.d.ts.map +1 -0
  70. package/dist/orchestration_1_0_60/queue.js +665 -0
  71. package/dist/orchestration_1_0_60/queue.js.map +1 -0
  72. package/dist/orchestration_1_0_60/runtime.d.ts +29 -0
  73. package/dist/orchestration_1_0_60/runtime.d.ts.map +1 -0
  74. package/dist/orchestration_1_0_60/runtime.js +196 -0
  75. package/dist/orchestration_1_0_60/runtime.js.map +1 -0
  76. package/dist/orchestration_1_0_60/state.d.ts +134 -0
  77. package/dist/orchestration_1_0_60/state.d.ts.map +1 -0
  78. package/dist/orchestration_1_0_60/state.js +116 -0
  79. package/dist/orchestration_1_0_60/state.js.map +1 -0
  80. package/dist/orchestration_1_0_60/turn.d.ts +32 -0
  81. package/dist/orchestration_1_0_60/turn.d.ts.map +1 -0
  82. package/dist/orchestration_1_0_60/turn.js +1154 -0
  83. package/dist/orchestration_1_0_60/turn.js.map +1 -0
  84. package/dist/orchestration_1_0_60/utils.d.ts +22 -0
  85. package/dist/orchestration_1_0_60/utils.d.ts.map +1 -0
  86. package/dist/orchestration_1_0_60/utils.js +226 -0
  87. package/dist/orchestration_1_0_60/utils.js.map +1 -0
  88. package/dist/pg-migrator.d.ts +19 -3
  89. package/dist/pg-migrator.d.ts.map +1 -1
  90. package/dist/pg-migrator.js +71 -2
  91. package/dist/pg-migrator.js.map +1 -1
  92. package/dist/session-messages.d.ts.map +1 -1
  93. package/dist/session-messages.js +49 -5
  94. package/dist/session-messages.js.map +1 -1
  95. package/dist/session-proxy.d.ts +1 -0
  96. package/dist/session-proxy.d.ts.map +1 -1
  97. package/dist/session-proxy.js +17 -6
  98. package/dist/session-proxy.js.map +1 -1
  99. package/dist/types.d.ts +8 -0
  100. package/dist/types.d.ts.map +1 -1
  101. package/dist/types.js.map +1 -1
  102. package/package.json +1 -1
  103. package/plugins/system/agents/default.agent.md +2 -2
@@ -152,8 +152,293 @@ export function CMS_MIGRATIONS(schema) {
152
152
  name: "list_sessions_page_owner",
153
153
  sql: migration_0028_list_sessions_page_owner(schema),
154
154
  },
155
+ {
156
+ version: "0029",
157
+ name: "session_visibility_shares",
158
+ steps: migration_0029_session_visibility_shares(schema),
159
+ },
160
+ {
161
+ version: "0030",
162
+ name: "register_user_update_on_sighting",
163
+ sql: migration_0030_register_user_update_on_sighting(schema),
164
+ },
165
+ {
166
+ version: "0031",
167
+ name: "list_users_directory",
168
+ sql: migration_0031_list_users_directory(schema),
169
+ },
170
+ {
171
+ version: "0032",
172
+ name: "adopt_email_keyed_grants",
173
+ sql: migration_0032_adopt_email_keyed_grants(schema),
174
+ },
175
+ {
176
+ version: "0033",
177
+ name: "grant_share_create_only_grantee",
178
+ sql: migration_0033_grant_share_create_only_grantee(schema),
179
+ },
155
180
  ];
156
181
  }
182
+ // ─── Migration 0033: grant path never overwrites directory identity ──
183
+ function migration_0033_grant_share_create_only_grantee(schema) {
184
+ const s = `"${schema}"`;
185
+ return `
186
+ -- 0033_grant_share_create_only_grantee: close a cross-user directory-tampering
187
+ -- hole. cms_grant_session_share resolved the grantee via cms_register_user,
188
+ -- which does UPDATE-on-sighting: a caller-supplied email/display_name
189
+ -- OVERWROTE an existing user's stored identity. Since a grant only needs the
190
+ -- caller to own the (throwaway) session being shared, ANY user could rewrite
191
+ -- ANY other user's display name / email as it appears in session lists, the
192
+ -- member directory, and share dialogs — impersonation, not an access bypass.
193
+ --
194
+ -- Fix: the grant path resolves-or-CREATES the grantee keyed ONLY on
195
+ -- (provider, subject), with NULL display fields — never touching an existing
196
+ -- row (this restores migration 0031's stated invariant that grants carry no
197
+ -- display_name). Names/emails enter the directory ONLY from a principal's own
198
+ -- sightings (login / session-create → cms_register_user). A brand-new grantee
199
+ -- placeholder therefore has display_name NULL, so it is correctly excluded
200
+ -- from cms_list_users until that person actually signs in (and email-keyed
201
+ -- placeholders still fold into the real principal via 0032 adoption).
202
+ --
203
+ -- Same 8-arg signature — CREATE OR REPLACE, rolling-deploy safe. The
204
+ -- p_email/p_display_name params are retained for signature stability but are
205
+ -- no longer written to the directory.
206
+
207
+ CREATE OR REPLACE FUNCTION ${s}.cms_grant_session_share(
208
+ p_session_id TEXT,
209
+ p_provider TEXT,
210
+ p_subject TEXT,
211
+ p_email TEXT,
212
+ p_display_name TEXT,
213
+ p_access TEXT,
214
+ p_granted_by_provider TEXT,
215
+ p_granted_by_subject TEXT
216
+ ) RETURNS VOID AS $$
217
+ DECLARE
218
+ v_root TEXT;
219
+ v_is_system BOOLEAN;
220
+ v_provider TEXT := NULLIF(BTRIM(p_provider), '');
221
+ v_subject TEXT := NULLIF(BTRIM(p_subject), '');
222
+ v_user_id BIGINT;
223
+ v_granted_by BIGINT;
224
+ BEGIN
225
+ IF v_provider IS NULL OR v_subject IS NULL THEN
226
+ RAISE EXCEPTION 'Grantee provider and subject are required';
227
+ END IF;
228
+ IF p_access NOT IN ('read', 'write') THEN
229
+ RAISE EXCEPTION 'Invalid share access "%" (expected read|write)', p_access;
230
+ END IF;
231
+ v_root := ${s}.cms_resolve_root_session(p_session_id);
232
+ IF v_root IS NULL THEN
233
+ RAISE EXCEPTION 'Session not found';
234
+ END IF;
235
+ SELECT sess.is_system INTO v_is_system FROM ${s}.sessions sess WHERE sess.session_id = v_root;
236
+ IF COALESCE(v_is_system, FALSE) THEN
237
+ RAISE EXCEPTION 'Cannot share a system session';
238
+ END IF;
239
+
240
+ -- Create-only: never overwrite a sighted row's identity. Display fields
241
+ -- come only from the grantee's own sightings, never from a grant.
242
+ INSERT INTO ${s}.users (provider, subject)
243
+ VALUES (v_provider, v_subject)
244
+ ON CONFLICT (provider, subject) DO NOTHING;
245
+ SELECT u.user_id INTO v_user_id
246
+ FROM ${s}.users u
247
+ WHERE u.provider = v_provider AND u.subject = v_subject;
248
+
249
+ IF p_granted_by_provider IS NOT NULL AND p_granted_by_subject IS NOT NULL THEN
250
+ SELECT u.user_id INTO v_granted_by FROM ${s}.users u
251
+ WHERE u.provider = BTRIM(p_granted_by_provider) AND u.subject = BTRIM(p_granted_by_subject);
252
+ END IF;
253
+
254
+ INSERT INTO ${s}.session_shares (session_id, user_id, access, granted_by)
255
+ VALUES (v_root, v_user_id, p_access, v_granted_by)
256
+ ON CONFLICT (session_id, user_id) DO UPDATE
257
+ SET access = EXCLUDED.access,
258
+ granted_by = EXCLUDED.granted_by,
259
+ granted_at = now();
260
+ END;
261
+ $$ LANGUAGE plpgsql;
262
+ `;
263
+ }
264
+ // ─── Migration 0032: adopt email-keyed grants on first sign-in ───
265
+ function migration_0032_adopt_email_keyed_grants(schema) {
266
+ const s = `"${schema}"`;
267
+ return `
268
+ -- 0032_adopt_email_keyed_grants: make "share with someone who has never
269
+ -- signed in" actually bind for providers with opaque subjects.
270
+ --
271
+ -- A share grant may target a user the granter identifies only by EMAIL —
272
+ -- someone who has never signed in. The grant path stores what it was given,
273
+ -- so that placeholder user row is keyed (provider, subject = typed email).
274
+ -- But real Entra principals are keyed by OID: on first sign-in the user
275
+ -- upserts a DIFFERENT row and the email-keyed grant would never match.
276
+ --
277
+ -- Fix: on every sighting that carries an email, cms_register_user adopts any
278
+ -- placeholder rows keyed (same provider, subject = this email, other user_id):
279
+ -- their shares move to the real user (keeping the stronger access where both
280
+ -- have one), residual references re-point, and the placeholder is deleted.
281
+ -- Same 4-arg signature as 0030 — CREATE OR REPLACE, rolling-deploy safe.
282
+
283
+ CREATE OR REPLACE FUNCTION ${s}.cms_register_user(
284
+ p_provider TEXT,
285
+ p_subject TEXT,
286
+ p_email TEXT,
287
+ p_display_name TEXT
288
+ ) RETURNS BIGINT AS $$
289
+ DECLARE
290
+ v_provider TEXT := NULLIF(BTRIM(p_provider), '');
291
+ v_subject TEXT := NULLIF(BTRIM(p_subject), '');
292
+ v_email TEXT := NULLIF(BTRIM(p_email), '');
293
+ v_display TEXT := NULLIF(BTRIM(p_display_name), '');
294
+ v_user_id BIGINT;
295
+ v_ghost BIGINT;
296
+ BEGIN
297
+ IF v_provider IS NULL OR v_subject IS NULL THEN
298
+ RAISE EXCEPTION 'User provider and subject are required';
299
+ END IF;
300
+
301
+ INSERT INTO ${s}.users (provider, subject, email, display_name)
302
+ VALUES (v_provider, v_subject, v_email, v_display)
303
+ ON CONFLICT (provider, subject) DO UPDATE
304
+ SET email = COALESCE(EXCLUDED.email, ${s}.users.email),
305
+ display_name = COALESCE(EXCLUDED.display_name, ${s}.users.display_name),
306
+ updated_at = now()
307
+ WHERE COALESCE(EXCLUDED.email, ${s}.users.email) IS DISTINCT FROM ${s}.users.email
308
+ OR COALESCE(EXCLUDED.display_name, ${s}.users.display_name) IS DISTINCT FROM ${s}.users.display_name;
309
+
310
+ SELECT user_id INTO v_user_id
311
+ FROM ${s}.users
312
+ WHERE provider = v_provider AND subject = v_subject;
313
+
314
+ -- Adopt placeholder rows keyed by this sighting's email (grants made
315
+ -- before the grantee ever signed in). The users table is a small
316
+ -- directory, so the case-insensitive subject scan is cheap.
317
+ IF v_email IS NOT NULL THEN
318
+ FOR v_ghost IN
319
+ SELECT u.user_id FROM ${s}.users u
320
+ WHERE u.provider = v_provider
321
+ AND LOWER(u.subject) = LOWER(v_email)
322
+ AND u.user_id <> v_user_id
323
+ LOOP
324
+ -- Move shares where the real user has none on that session.
325
+ UPDATE ${s}.session_shares ss SET user_id = v_user_id
326
+ WHERE ss.user_id = v_ghost
327
+ AND NOT EXISTS (
328
+ SELECT 1 FROM ${s}.session_shares e
329
+ WHERE e.session_id = ss.session_id AND e.user_id = v_user_id
330
+ );
331
+ -- Where both have a grant, keep the stronger access.
332
+ UPDATE ${s}.session_shares e SET access = 'write'
333
+ FROM ${s}.session_shares g
334
+ WHERE g.user_id = v_ghost AND g.session_id = e.session_id
335
+ AND e.user_id = v_user_id AND g.access = 'write' AND e.access <> 'write';
336
+ DELETE FROM ${s}.session_shares WHERE user_id = v_ghost;
337
+ -- Defensive: placeholders never sign in, so they shouldn't own or
338
+ -- grant anything — but re-point rather than break the FK delete.
339
+ UPDATE ${s}.session_shares SET granted_by = v_user_id WHERE granted_by = v_ghost;
340
+ UPDATE ${s}.session_owners SET user_id = v_user_id WHERE user_id = v_ghost;
341
+ UPDATE ${s}.session_group_owners SET user_id = v_user_id WHERE user_id = v_ghost;
342
+ DELETE FROM ${s}.users WHERE user_id = v_ghost;
343
+ END LOOP;
344
+ END IF;
345
+
346
+ RETURN v_user_id;
347
+ END;
348
+ $$ LANGUAGE plpgsql;
349
+ `;
350
+ }
351
+ // ─── Migration 0031: user directory for share autocomplete ───────
352
+ function migration_0031_list_users_directory(schema) {
353
+ const s = `"${schema}"`;
354
+ return `
355
+ -- 0031_list_users_directory: a read-only member directory so the share UI can
356
+ -- autocomplete a grantee by NAME (resolving to the stable provider/subject the
357
+ -- grant is keyed on) instead of asking for an internal id. Excludes the
358
+ -- synthetic system/local principals. Newest-updated first, capped.
359
+
360
+ CREATE OR REPLACE FUNCTION ${s}.cms_list_users(
361
+ p_limit INT
362
+ ) RETURNS TABLE (
363
+ provider TEXT,
364
+ subject TEXT,
365
+ email TEXT,
366
+ display_name TEXT
367
+ ) AS $$
368
+ DECLARE
369
+ v_limit INT := GREATEST(1, LEAST(COALESCE(p_limit, 500), 2000));
370
+ BEGIN
371
+ RETURN QUERY
372
+ SELECT u.provider, u.subject, u.email, u.display_name
373
+ FROM ${s}.users u
374
+ WHERE NOT (u.provider = 'system' AND u.subject = 'system')
375
+ AND NOT (u.provider = 'local' AND u.subject = 'default')
376
+ -- Only sighted members (a real login/session-create sets display_name).
377
+ -- Excludes rows created solely by a mistyped raw-id grant.
378
+ AND u.display_name IS NOT NULL
379
+ ORDER BY u.updated_at DESC, u.user_id DESC
380
+ LIMIT v_limit;
381
+ END;
382
+ $$ LANGUAGE plpgsql;
383
+ `;
384
+ }
385
+ // ─── Migration 0030: user profile update-on-sighting ─────────────
386
+ function migration_0030_register_user_update_on_sighting(schema) {
387
+ const s = `"${schema}"`;
388
+ return `
389
+ -- 0030_register_user_update_on_sighting: flip cms_register_user from
390
+ -- first-seen-write-wins to update-on-sighting for the display fields.
391
+ --
392
+ -- Why: a user row is created the first time a principal is *seen*, which can
393
+ -- be a share grant (cms_grant_session_share) carrying only (provider, subject)
394
+ -- — no email/display_name, because the granter rarely knows them. Under the
395
+ -- old first-seen rule that null-display row was frozen, so when the grantee
396
+ -- later signed in and created sessions, the owner-initials UI still rendered
397
+ -- "?" for their own sessions. The security-model share dialog and audit views
398
+ -- make stale/missing display names a real cost (see the user-lifecycle section
399
+ -- of docs/proposals/user-admin-security-model.md).
400
+ --
401
+ -- New rule: on every sighting, COALESCE a non-empty incoming email/display_name
402
+ -- over the stored value. A sighting that carries the fields (a real login /
403
+ -- session create) backfills or refreshes them; a sighting that carries nulls
404
+ -- (a share grant) leaves existing values untouched. The identity key
405
+ -- (provider, subject) is never changed. Same signature — CREATE OR REPLACE.
406
+
407
+ CREATE OR REPLACE FUNCTION ${s}.cms_register_user(
408
+ p_provider TEXT,
409
+ p_subject TEXT,
410
+ p_email TEXT,
411
+ p_display_name TEXT
412
+ ) RETURNS BIGINT AS $$
413
+ DECLARE
414
+ v_provider TEXT := NULLIF(BTRIM(p_provider), '');
415
+ v_subject TEXT := NULLIF(BTRIM(p_subject), '');
416
+ v_email TEXT := NULLIF(BTRIM(p_email), '');
417
+ v_display TEXT := NULLIF(BTRIM(p_display_name), '');
418
+ v_user_id BIGINT;
419
+ BEGIN
420
+ IF v_provider IS NULL OR v_subject IS NULL THEN
421
+ RAISE EXCEPTION 'User provider and subject are required';
422
+ END IF;
423
+
424
+ INSERT INTO ${s}.users (provider, subject, email, display_name)
425
+ VALUES (v_provider, v_subject, v_email, v_display)
426
+ ON CONFLICT (provider, subject) DO UPDATE
427
+ SET email = COALESCE(EXCLUDED.email, ${s}.users.email),
428
+ display_name = COALESCE(EXCLUDED.display_name, ${s}.users.display_name),
429
+ updated_at = now()
430
+ WHERE COALESCE(EXCLUDED.email, ${s}.users.email) IS DISTINCT FROM ${s}.users.email
431
+ OR COALESCE(EXCLUDED.display_name, ${s}.users.display_name) IS DISTINCT FROM ${s}.users.display_name;
432
+
433
+ SELECT user_id INTO v_user_id
434
+ FROM ${s}.users
435
+ WHERE provider = v_provider AND subject = v_subject;
436
+
437
+ RETURN v_user_id;
438
+ END;
439
+ $$ LANGUAGE plpgsql;
440
+ `;
441
+ }
157
442
  // ─── Migration 0001: Baseline ────────────────────────────────────
158
443
  function migration_0001_baseline(schema) {
159
444
  const s = `"${schema}"`;
@@ -5130,4 +5415,848 @@ END;
5130
5415
  $$ LANGUAGE plpgsql;
5131
5416
  `;
5132
5417
  }
5418
+ // ─── Migration 0029: session visibility, shares, root stamping, authz audit ─
5419
+ function migration_0029_session_visibility_shares(schema) {
5420
+ const s = `"${schema}"`;
5421
+ // Non-transactional steps (see MigrationEntry.steps). The original
5422
+ // single-transaction shape held the ALTER's ACCESS EXCLUSIVE lock on
5423
+ // `sessions` through a full-table backfill and a non-CONCURRENT index
5424
+ // build — on a large, busy table that freezes every live reader/writer
5425
+ // for the whole migration. Hardened shape:
5426
+ // 1. The two ALTERs commit alone under a 5s lock_timeout (metadata-only:
5427
+ // constant default, no rewrite). A blocked ALTER fails fast and the
5428
+ // worker's CMS-init retry re-attempts, instead of queueing the fleet
5429
+ // behind the lock request.
5430
+ // 2. The backfill runs level-by-level in a DO block, COMMITting between
5431
+ // passes (PG11+), so it takes only brief row locks — never a table
5432
+ // lock. Cycles self-exhaust (their members never gain a root);
5433
+ // orphans and cycle members fall back to self-rooted, matching the
5434
+ // original recursive-CTE semantics.
5435
+ // 3. The sessions index builds with CREATE INDEX CONCURRENTLY (writes
5436
+ // proceed during the build), with an invalid-leftover sweep first —
5437
+ // a died CIC leaves an INVALID index that IF NOT EXISTS would
5438
+ // otherwise treat as present.
5439
+ // 4. The new tables + all function DDL stay one atomic step, so
5440
+ // DROP+CREATE proc swaps expose no missing-function window.
5441
+ // Every step is idempotent: a mid-way failure re-runs all steps.
5442
+ const step_columns = `
5443
+ BEGIN;
5444
+ SET LOCAL lock_timeout = '5s';
5445
+ ALTER TABLE ${s}.sessions ADD COLUMN IF NOT EXISTS visibility TEXT NOT NULL DEFAULT 'private';
5446
+ ALTER TABLE ${s}.sessions ADD COLUMN IF NOT EXISTS root_session_id TEXT;
5447
+ COMMIT;
5448
+ `;
5449
+ const step_backfill = `
5450
+ DO $harden$
5451
+ DECLARE
5452
+ affected BIGINT;
5453
+ passes INT := 0;
5454
+ BEGIN
5455
+ -- Roots: parentless rows own their tree.
5456
+ UPDATE ${s}.sessions
5457
+ SET root_session_id = session_id
5458
+ WHERE parent_session_id IS NULL AND root_session_id IS NULL;
5459
+ COMMIT;
5460
+
5461
+ -- Propagate one tree level per pass, committing between passes.
5462
+ LOOP
5463
+ UPDATE ${s}.sessions AS child
5464
+ SET root_session_id = parent.root_session_id
5465
+ FROM ${s}.sessions AS parent
5466
+ WHERE child.parent_session_id = parent.session_id
5467
+ AND child.root_session_id IS NULL
5468
+ AND parent.root_session_id IS NOT NULL;
5469
+ GET DIAGNOSTICS affected = ROW_COUNT;
5470
+ EXIT WHEN affected = 0;
5471
+ COMMIT;
5472
+ passes := passes + 1;
5473
+ EXIT WHEN passes >= 128;
5474
+ END LOOP;
5475
+
5476
+ -- Orphans (hard-deleted parents) and cycle members fall back to self.
5477
+ UPDATE ${s}.sessions
5478
+ SET root_session_id = session_id
5479
+ WHERE root_session_id IS NULL;
5480
+ END
5481
+ $harden$;
5482
+ `;
5483
+ const step_drop_invalid_index = `
5484
+ DO $harden$
5485
+ BEGIN
5486
+ IF EXISTS (
5487
+ SELECT 1
5488
+ FROM pg_index i
5489
+ JOIN pg_class c ON c.oid = i.indexrelid
5490
+ JOIN pg_namespace n ON n.oid = c.relnamespace
5491
+ WHERE n.nspname = '${schema}'
5492
+ AND c.relname = 'idx_${schema}_sessions_root'
5493
+ AND NOT i.indisvalid
5494
+ ) THEN
5495
+ EXECUTE 'DROP INDEX ${s}.idx_${schema}_sessions_root';
5496
+ END IF;
5497
+ END
5498
+ $harden$;
5499
+ `;
5500
+ const step_index = `
5501
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_${schema}_sessions_root
5502
+ ON ${s}.sessions(root_session_id);
5503
+ `;
5504
+ const step_tables_and_functions = `
5505
+ -- 0029_session_visibility_shares: the security-model schema
5506
+ -- (docs/proposals/user-admin-security-model.md).
5507
+ --
5508
+ -- Adds:
5509
+ -- sessions.visibility 'private' | 'shared_read' | 'shared_write'.
5510
+ -- Meaningful on ROOT sessions only — access for a
5511
+ -- child always resolves through its root.
5512
+ -- sessions.root_session_id Denormalized tree root, stamped at create so
5513
+ -- list filtering is one join, not a recursive
5514
+ -- walk. Backfilled here for existing rows.
5515
+ -- session_shares Targeted per-user grants (read|write), keyed on
5516
+ -- the ROOT session id.
5517
+ -- authz_audit Denials, break-glass reads, share changes.
5518
+ --
5519
+ -- Access predicate (evaluated in cms_get_session_access / list filtering):
5520
+ -- canRead := admin || root.owner == viewer || root.visibility in
5521
+ -- (shared_read, shared_write) || share(root, viewer) is set
5522
+ -- canWrite := admin || root.owner == viewer || root.visibility ==
5523
+ -- shared_write || share(root, viewer) == write
5524
+ -- Role (admin or not) is the caller's concern; these procs only report facts.
5525
+
5526
+ CREATE TABLE IF NOT EXISTS ${s}.session_shares (
5527
+ session_id TEXT NOT NULL REFERENCES ${s}.sessions(session_id) ON DELETE CASCADE,
5528
+ user_id BIGINT NOT NULL REFERENCES ${s}.users(user_id),
5529
+ access TEXT NOT NULL,
5530
+ granted_by BIGINT REFERENCES ${s}.users(user_id),
5531
+ granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
5532
+ PRIMARY KEY (session_id, user_id)
5533
+ );
5534
+
5535
+ CREATE INDEX IF NOT EXISTS idx_${schema}_session_shares_user
5536
+ ON ${s}.session_shares(user_id);
5537
+
5538
+ CREATE TABLE IF NOT EXISTS ${s}.authz_audit (
5539
+ audit_id BIGSERIAL PRIMARY KEY,
5540
+ occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
5541
+ actor_provider TEXT,
5542
+ actor_subject TEXT,
5543
+ actor_display TEXT,
5544
+ action TEXT NOT NULL,
5545
+ session_id TEXT,
5546
+ target TEXT,
5547
+ decision TEXT NOT NULL,
5548
+ reason TEXT,
5549
+ details JSONB NOT NULL DEFAULT '{}'
5550
+ );
5551
+
5552
+ CREATE INDEX IF NOT EXISTS idx_${schema}_authz_audit_session
5553
+ ON ${s}.authz_audit(session_id, occurred_at DESC);
5554
+ CREATE INDEX IF NOT EXISTS idx_${schema}_authz_audit_occurred
5555
+ ON ${s}.authz_audit(occurred_at DESC);
5556
+
5557
+ -- ── cms_resolve_root_session ─────────────────────────────────────
5558
+ -- Root id for a live session; falls back to the session itself when the
5559
+ -- recorded root row is gone (hard-delete edge). NULL when the session does
5560
+ -- not exist or is soft-deleted.
5561
+ CREATE OR REPLACE FUNCTION ${s}.cms_resolve_root_session(
5562
+ p_session_id TEXT
5563
+ ) RETURNS TEXT AS $$
5564
+ DECLARE
5565
+ v_root TEXT;
5566
+ BEGIN
5567
+ SELECT COALESCE(sess.root_session_id, sess.session_id) INTO v_root
5568
+ FROM ${s}.sessions sess
5569
+ WHERE sess.session_id = p_session_id AND sess.deleted_at IS NULL;
5570
+ IF v_root IS NULL THEN
5571
+ RETURN NULL;
5572
+ END IF;
5573
+ IF NOT EXISTS (SELECT 1 FROM ${s}.sessions r WHERE r.session_id = v_root) THEN
5574
+ RETURN p_session_id;
5575
+ END IF;
5576
+ RETURN v_root;
5577
+ END;
5578
+ $$ LANGUAGE plpgsql;
5579
+
5580
+ -- ── cms_create_session (visibility + root overload) ──────────────
5581
+ -- 10-arg overload: 0026's 9-arg body plus p_visibility, root stamping.
5582
+ -- No parameter defaults — defaults would make 9-arg calls ambiguous against
5583
+ -- the 0026 overload, which stays for older writers during rolling deploys.
5584
+ CREATE OR REPLACE FUNCTION ${s}.cms_create_session(
5585
+ p_session_id TEXT,
5586
+ p_model TEXT,
5587
+ p_reasoning_effort TEXT,
5588
+ p_parent_session_id TEXT,
5589
+ p_is_system BOOLEAN,
5590
+ p_agent_id TEXT,
5591
+ p_splash TEXT,
5592
+ p_group_id TEXT,
5593
+ p_splash_mobile TEXT,
5594
+ p_visibility TEXT
5595
+ ) RETURNS VOID AS $$
5596
+ DECLARE
5597
+ v_reasoning_effort TEXT := NULLIF(BTRIM(p_reasoning_effort), '');
5598
+ v_group_id TEXT := NULLIF(BTRIM(p_group_id), '');
5599
+ v_root TEXT;
5600
+ v_visibility TEXT := CASE
5601
+ WHEN p_visibility IN ('private', 'shared_read', 'shared_write') THEN p_visibility
5602
+ ELSE 'private'
5603
+ END;
5604
+ BEGIN
5605
+ IF p_parent_session_id IS NOT NULL THEN
5606
+ SELECT COALESCE(parent.root_session_id, parent.session_id), COALESCE(v_group_id, parent.group_id)
5607
+ INTO v_root, v_group_id
5608
+ FROM ${s}.sessions parent
5609
+ WHERE parent.session_id = p_parent_session_id;
5610
+ END IF;
5611
+ v_root := COALESCE(v_root, p_session_id);
5612
+
5613
+ INSERT INTO ${s}.sessions
5614
+ (session_id, model, reasoning_effort, parent_session_id, is_system, agent_id, splash, splash_mobile, group_id, root_session_id, visibility)
5615
+ VALUES
5616
+ (p_session_id, p_model, v_reasoning_effort, p_parent_session_id, p_is_system, p_agent_id, p_splash, p_splash_mobile, v_group_id, v_root, v_visibility)
5617
+ ON CONFLICT (session_id) DO UPDATE
5618
+ SET model = EXCLUDED.model,
5619
+ reasoning_effort = EXCLUDED.reasoning_effort,
5620
+ parent_session_id = EXCLUDED.parent_session_id,
5621
+ is_system = EXCLUDED.is_system,
5622
+ agent_id = EXCLUDED.agent_id,
5623
+ splash = EXCLUDED.splash,
5624
+ splash_mobile = EXCLUDED.splash_mobile,
5625
+ group_id = EXCLUDED.group_id,
5626
+ root_session_id = EXCLUDED.root_session_id,
5627
+ visibility = EXCLUDED.visibility,
5628
+ deleted_at = NULL,
5629
+ updated_at = now(),
5630
+ state = 'pending',
5631
+ orchestration_id = NULL,
5632
+ last_error = NULL,
5633
+ last_active_at = NULL,
5634
+ current_iteration = 0,
5635
+ wait_reason = NULL,
5636
+ title_locked = FALSE
5637
+ WHERE ${s}.sessions.deleted_at IS NOT NULL;
5638
+
5639
+ INSERT INTO ${s}.session_metric_summaries
5640
+ (session_id, agent_id, model, reasoning_effort, parent_session_id)
5641
+ VALUES
5642
+ (p_session_id, p_agent_id, p_model, v_reasoning_effort, p_parent_session_id)
5643
+ ON CONFLICT (session_id) DO UPDATE
5644
+ SET agent_id = COALESCE(${s}.session_metric_summaries.agent_id, EXCLUDED.agent_id),
5645
+ model = COALESCE(${s}.session_metric_summaries.model, EXCLUDED.model),
5646
+ reasoning_effort = COALESCE(${s}.session_metric_summaries.reasoning_effort, EXCLUDED.reasoning_effort),
5647
+ parent_session_id = COALESCE(${s}.session_metric_summaries.parent_session_id, EXCLUDED.parent_session_id),
5648
+ updated_at = now();
5649
+ END;
5650
+ $$ LANGUAGE plpgsql;
5651
+
5652
+ -- ── cms_set_session_visibility ───────────────────────────────────
5653
+ -- Applies to the ROOT of the given session; refuses system trees.
5654
+ CREATE OR REPLACE FUNCTION ${s}.cms_set_session_visibility(
5655
+ p_session_id TEXT,
5656
+ p_visibility TEXT
5657
+ ) RETURNS VOID AS $$
5658
+ DECLARE
5659
+ v_root TEXT;
5660
+ v_is_system BOOLEAN;
5661
+ BEGIN
5662
+ IF p_visibility NOT IN ('private', 'shared_read', 'shared_write') THEN
5663
+ RAISE EXCEPTION 'Invalid visibility "%" (expected private|shared_read|shared_write)', p_visibility;
5664
+ END IF;
5665
+ v_root := ${s}.cms_resolve_root_session(p_session_id);
5666
+ IF v_root IS NULL THEN
5667
+ RAISE EXCEPTION 'Session not found';
5668
+ END IF;
5669
+ SELECT sess.is_system INTO v_is_system FROM ${s}.sessions sess WHERE sess.session_id = v_root;
5670
+ IF COALESCE(v_is_system, FALSE) THEN
5671
+ RAISE EXCEPTION 'Cannot change visibility of a system session';
5672
+ END IF;
5673
+ UPDATE ${s}.sessions SET visibility = p_visibility, updated_at = now()
5674
+ WHERE session_id = v_root;
5675
+ END;
5676
+ $$ LANGUAGE plpgsql;
5677
+
5678
+ -- ── cms_grant_session_share ──────────────────────────────────────
5679
+ -- Grants (or updates) a targeted share on the ROOT of the given session.
5680
+ CREATE OR REPLACE FUNCTION ${s}.cms_grant_session_share(
5681
+ p_session_id TEXT,
5682
+ p_provider TEXT,
5683
+ p_subject TEXT,
5684
+ p_email TEXT,
5685
+ p_display_name TEXT,
5686
+ p_access TEXT,
5687
+ p_granted_by_provider TEXT,
5688
+ p_granted_by_subject TEXT
5689
+ ) RETURNS VOID AS $$
5690
+ DECLARE
5691
+ v_root TEXT;
5692
+ v_is_system BOOLEAN;
5693
+ v_user_id BIGINT;
5694
+ v_granted_by BIGINT;
5695
+ BEGIN
5696
+ IF p_access NOT IN ('read', 'write') THEN
5697
+ RAISE EXCEPTION 'Invalid share access "%" (expected read|write)', p_access;
5698
+ END IF;
5699
+ v_root := ${s}.cms_resolve_root_session(p_session_id);
5700
+ IF v_root IS NULL THEN
5701
+ RAISE EXCEPTION 'Session not found';
5702
+ END IF;
5703
+ SELECT sess.is_system INTO v_is_system FROM ${s}.sessions sess WHERE sess.session_id = v_root;
5704
+ IF COALESCE(v_is_system, FALSE) THEN
5705
+ RAISE EXCEPTION 'Cannot share a system session';
5706
+ END IF;
5707
+
5708
+ v_user_id := ${s}.cms_register_user(p_provider, p_subject, p_email, p_display_name);
5709
+ IF p_granted_by_provider IS NOT NULL AND p_granted_by_subject IS NOT NULL THEN
5710
+ SELECT u.user_id INTO v_granted_by FROM ${s}.users u
5711
+ WHERE u.provider = BTRIM(p_granted_by_provider) AND u.subject = BTRIM(p_granted_by_subject);
5712
+ END IF;
5713
+
5714
+ INSERT INTO ${s}.session_shares (session_id, user_id, access, granted_by)
5715
+ VALUES (v_root, v_user_id, p_access, v_granted_by)
5716
+ ON CONFLICT (session_id, user_id) DO UPDATE
5717
+ SET access = EXCLUDED.access,
5718
+ granted_by = EXCLUDED.granted_by,
5719
+ granted_at = now();
5720
+ END;
5721
+ $$ LANGUAGE plpgsql;
5722
+
5723
+ -- ── cms_revoke_session_share ─────────────────────────────────────
5724
+ CREATE OR REPLACE FUNCTION ${s}.cms_revoke_session_share(
5725
+ p_session_id TEXT,
5726
+ p_provider TEXT,
5727
+ p_subject TEXT
5728
+ ) RETURNS VOID AS $$
5729
+ DECLARE
5730
+ v_root TEXT;
5731
+ BEGIN
5732
+ v_root := ${s}.cms_resolve_root_session(p_session_id);
5733
+ IF v_root IS NULL THEN
5734
+ RAISE EXCEPTION 'Session not found';
5735
+ END IF;
5736
+ DELETE FROM ${s}.session_shares sh
5737
+ USING ${s}.users u
5738
+ WHERE sh.session_id = v_root
5739
+ AND u.user_id = sh.user_id
5740
+ AND u.provider = BTRIM(p_provider)
5741
+ AND u.subject = BTRIM(p_subject);
5742
+ END;
5743
+ $$ LANGUAGE plpgsql;
5744
+
5745
+ -- ── cms_list_session_shares ──────────────────────────────────────
5746
+ DROP FUNCTION IF EXISTS ${s}.cms_list_session_shares(TEXT);
5747
+ CREATE FUNCTION ${s}.cms_list_session_shares(
5748
+ p_session_id TEXT
5749
+ ) RETURNS TABLE (
5750
+ provider TEXT,
5751
+ subject TEXT,
5752
+ email TEXT,
5753
+ display_name TEXT,
5754
+ access TEXT,
5755
+ granted_at TIMESTAMPTZ,
5756
+ granted_by_display TEXT
5757
+ ) AS $$
5758
+ DECLARE
5759
+ v_root TEXT;
5760
+ BEGIN
5761
+ v_root := ${s}.cms_resolve_root_session(p_session_id);
5762
+ IF v_root IS NULL THEN
5763
+ RETURN;
5764
+ END IF;
5765
+ RETURN QUERY
5766
+ SELECT u.provider, u.subject, u.email, u.display_name, sh.access, sh.granted_at,
5767
+ gb.display_name AS granted_by_display
5768
+ FROM ${s}.session_shares sh
5769
+ JOIN ${s}.users u ON u.user_id = sh.user_id
5770
+ LEFT JOIN ${s}.users gb ON gb.user_id = sh.granted_by
5771
+ WHERE sh.session_id = v_root
5772
+ ORDER BY sh.granted_at ASC;
5773
+ END;
5774
+ $$ LANGUAGE plpgsql;
5775
+
5776
+ -- ── cms_get_session_access ───────────────────────────────────────
5777
+ -- One round-trip access snapshot for the enforcement predicate: the root's
5778
+ -- system flag, visibility, owner, and the viewer's targeted share. Reports
5779
+ -- facts only; the caller combines them with the caller's role.
5780
+ DROP FUNCTION IF EXISTS ${s}.cms_get_session_access(TEXT, TEXT, TEXT);
5781
+ CREATE FUNCTION ${s}.cms_get_session_access(
5782
+ p_session_id TEXT,
5783
+ p_viewer_provider TEXT,
5784
+ p_viewer_subject TEXT
5785
+ ) RETURNS TABLE (
5786
+ root_session_id TEXT,
5787
+ is_system BOOLEAN,
5788
+ visibility TEXT,
5789
+ owner_provider TEXT,
5790
+ owner_subject TEXT,
5791
+ owner_email TEXT,
5792
+ owner_display_name TEXT,
5793
+ viewer_is_owner BOOLEAN,
5794
+ viewer_share_access TEXT
5795
+ ) AS $$
5796
+ DECLARE
5797
+ v_root TEXT;
5798
+ BEGIN
5799
+ v_root := ${s}.cms_resolve_root_session(p_session_id);
5800
+ IF v_root IS NULL THEN
5801
+ RETURN; -- zero rows: session missing or soft-deleted
5802
+ END IF;
5803
+ RETURN QUERY
5804
+ SELECT
5805
+ v_root,
5806
+ COALESCE(r.is_system, FALSE),
5807
+ COALESCE(r.visibility, 'private'),
5808
+ u.provider,
5809
+ u.subject,
5810
+ u.email,
5811
+ u.display_name,
5812
+ (u.provider IS NOT NULL
5813
+ AND u.provider = BTRIM(p_viewer_provider)
5814
+ AND u.subject = BTRIM(p_viewer_subject)),
5815
+ (SELECT sh.access
5816
+ FROM ${s}.session_shares sh
5817
+ JOIN ${s}.users vu ON vu.user_id = sh.user_id
5818
+ WHERE sh.session_id = v_root
5819
+ AND vu.provider = BTRIM(p_viewer_provider)
5820
+ AND vu.subject = BTRIM(p_viewer_subject))
5821
+ FROM ${s}.sessions r
5822
+ LEFT JOIN ${s}.session_owners so ON so.session_id = r.session_id
5823
+ LEFT JOIN ${s}.users u ON u.user_id = so.user_id
5824
+ WHERE r.session_id = v_root;
5825
+ END;
5826
+ $$ LANGUAGE plpgsql;
5827
+
5828
+ -- ── cms_record_authz_audit / cms_list_authz_audit ────────────────
5829
+ CREATE OR REPLACE FUNCTION ${s}.cms_record_authz_audit(
5830
+ p_actor_provider TEXT,
5831
+ p_actor_subject TEXT,
5832
+ p_actor_display TEXT,
5833
+ p_action TEXT,
5834
+ p_session_id TEXT,
5835
+ p_target TEXT,
5836
+ p_decision TEXT,
5837
+ p_reason TEXT,
5838
+ p_details JSONB
5839
+ ) RETURNS VOID AS $$
5840
+ BEGIN
5841
+ INSERT INTO ${s}.authz_audit
5842
+ (actor_provider, actor_subject, actor_display, action, session_id, target, decision, reason, details)
5843
+ VALUES
5844
+ (p_actor_provider, p_actor_subject, p_actor_display, p_action, p_session_id, p_target, p_decision, p_reason, COALESCE(p_details, '{}'::JSONB));
5845
+ END;
5846
+ $$ LANGUAGE plpgsql;
5847
+
5848
+ DROP FUNCTION IF EXISTS ${s}.cms_list_authz_audit(INT, TEXT);
5849
+ CREATE FUNCTION ${s}.cms_list_authz_audit(
5850
+ p_limit INT,
5851
+ p_session_id TEXT
5852
+ ) RETURNS TABLE (
5853
+ audit_id BIGINT,
5854
+ occurred_at TIMESTAMPTZ,
5855
+ actor_provider TEXT,
5856
+ actor_subject TEXT,
5857
+ actor_display TEXT,
5858
+ action TEXT,
5859
+ session_id TEXT,
5860
+ target TEXT,
5861
+ decision TEXT,
5862
+ reason TEXT,
5863
+ details JSONB
5864
+ ) AS $$
5865
+ DECLARE
5866
+ v_limit INT := GREATEST(1, LEAST(COALESCE(p_limit, 100), 500));
5867
+ BEGIN
5868
+ RETURN QUERY
5869
+ SELECT a.audit_id, a.occurred_at, a.actor_provider, a.actor_subject, a.actor_display,
5870
+ a.action, a.session_id, a.target, a.decision, a.reason, a.details
5871
+ FROM ${s}.authz_audit a
5872
+ WHERE p_session_id IS NULL
5873
+ OR a.session_id = p_session_id
5874
+ OR a.session_id IN (
5875
+ SELECT sess.session_id FROM ${s}.sessions sess
5876
+ WHERE COALESCE(sess.root_session_id, sess.session_id) = ${s}.cms_resolve_root_session(p_session_id)
5877
+ )
5878
+ ORDER BY a.occurred_at DESC, a.audit_id DESC
5879
+ LIMIT v_limit;
5880
+ END;
5881
+ $$ LANGUAGE plpgsql;
5882
+
5883
+ -- ── Read procs: append visibility + root_session_id ──────────────
5884
+ -- All four session read procs gain the two new columns AT THE END of the
5885
+ -- return shape (after splash_mobile) so rowToSessionRow handles every path
5886
+ -- identically. Return-shape changes require drop-then-create.
5887
+
5888
+ -- cms_get_session
5889
+ DROP FUNCTION IF EXISTS ${s}.cms_get_session(TEXT);
5890
+ CREATE FUNCTION ${s}.cms_get_session(
5891
+ p_session_id TEXT
5892
+ ) RETURNS TABLE (
5893
+ session_id TEXT,
5894
+ orchestration_id TEXT,
5895
+ title TEXT,
5896
+ title_locked BOOLEAN,
5897
+ state TEXT,
5898
+ model TEXT,
5899
+ reasoning_effort TEXT,
5900
+ group_id TEXT,
5901
+ short_summary TEXT,
5902
+ summary_state JSONB,
5903
+ summary_updated_at TIMESTAMPTZ,
5904
+ created_at TIMESTAMPTZ,
5905
+ updated_at TIMESTAMPTZ,
5906
+ last_active_at TIMESTAMPTZ,
5907
+ deleted_at TIMESTAMPTZ,
5908
+ current_iteration INTEGER,
5909
+ last_error TEXT,
5910
+ parent_session_id TEXT,
5911
+ wait_reason TEXT,
5912
+ is_system BOOLEAN,
5913
+ agent_id TEXT,
5914
+ splash TEXT,
5915
+ owner_provider TEXT,
5916
+ owner_subject TEXT,
5917
+ owner_email TEXT,
5918
+ owner_display_name TEXT,
5919
+ active_turn_index INTEGER,
5920
+ splash_mobile TEXT,
5921
+ visibility TEXT,
5922
+ root_session_id TEXT
5923
+ ) AS $$
5924
+ BEGIN
5925
+ RETURN QUERY
5926
+ SELECT
5927
+ sess.session_id,
5928
+ sess.orchestration_id,
5929
+ sess.title,
5930
+ sess.title_locked,
5931
+ sess.state,
5932
+ sess.model,
5933
+ sess.reasoning_effort,
5934
+ sess.group_id,
5935
+ sess.short_summary,
5936
+ sess.summary_state,
5937
+ sess.summary_updated_at,
5938
+ sess.created_at,
5939
+ sess.updated_at,
5940
+ sess.last_active_at,
5941
+ sess.deleted_at,
5942
+ sess.current_iteration,
5943
+ sess.last_error,
5944
+ sess.parent_session_id,
5945
+ sess.wait_reason,
5946
+ sess.is_system,
5947
+ sess.agent_id,
5948
+ sess.splash,
5949
+ u.provider AS owner_provider,
5950
+ u.subject AS owner_subject,
5951
+ u.email AS owner_email,
5952
+ u.display_name AS owner_display_name,
5953
+ sess.active_turn_index,
5954
+ sess.splash_mobile,
5955
+ sess.visibility,
5956
+ sess.root_session_id
5957
+ FROM ${s}.sessions sess
5958
+ LEFT JOIN ${s}.session_owners so ON so.session_id = sess.session_id
5959
+ LEFT JOIN ${s}.users u ON u.user_id = so.user_id
5960
+ WHERE sess.session_id = p_session_id AND sess.deleted_at IS NULL;
5961
+ END;
5962
+ $$ LANGUAGE plpgsql;
5963
+
5964
+ -- cms_list_sessions / cms_list_group_sessions (shapes must stay identical)
5965
+ DROP FUNCTION IF EXISTS ${s}.cms_list_group_sessions(TEXT);
5966
+ DROP FUNCTION IF EXISTS ${s}.cms_list_sessions();
5967
+ CREATE FUNCTION ${s}.cms_list_sessions()
5968
+ RETURNS TABLE (
5969
+ session_id TEXT,
5970
+ orchestration_id TEXT,
5971
+ title TEXT,
5972
+ title_locked BOOLEAN,
5973
+ state TEXT,
5974
+ model TEXT,
5975
+ reasoning_effort TEXT,
5976
+ group_id TEXT,
5977
+ short_summary TEXT,
5978
+ summary_state JSONB,
5979
+ summary_updated_at TIMESTAMPTZ,
5980
+ created_at TIMESTAMPTZ,
5981
+ updated_at TIMESTAMPTZ,
5982
+ last_active_at TIMESTAMPTZ,
5983
+ deleted_at TIMESTAMPTZ,
5984
+ current_iteration INTEGER,
5985
+ last_error TEXT,
5986
+ parent_session_id TEXT,
5987
+ wait_reason TEXT,
5988
+ is_system BOOLEAN,
5989
+ agent_id TEXT,
5990
+ splash TEXT,
5991
+ owner_provider TEXT,
5992
+ owner_subject TEXT,
5993
+ owner_email TEXT,
5994
+ owner_display_name TEXT,
5995
+ splash_mobile TEXT,
5996
+ visibility TEXT,
5997
+ root_session_id TEXT
5998
+ ) AS $$
5999
+ BEGIN
6000
+ RETURN QUERY
6001
+ SELECT
6002
+ sess.session_id,
6003
+ sess.orchestration_id,
6004
+ sess.title,
6005
+ sess.title_locked,
6006
+ sess.state,
6007
+ sess.model,
6008
+ sess.reasoning_effort,
6009
+ sess.group_id,
6010
+ sess.short_summary,
6011
+ sess.summary_state,
6012
+ sess.summary_updated_at,
6013
+ sess.created_at,
6014
+ sess.updated_at,
6015
+ sess.last_active_at,
6016
+ sess.deleted_at,
6017
+ sess.current_iteration,
6018
+ sess.last_error,
6019
+ sess.parent_session_id,
6020
+ sess.wait_reason,
6021
+ sess.is_system,
6022
+ sess.agent_id,
6023
+ sess.splash,
6024
+ u.provider AS owner_provider,
6025
+ u.subject AS owner_subject,
6026
+ u.email AS owner_email,
6027
+ u.display_name AS owner_display_name,
6028
+ sess.splash_mobile,
6029
+ sess.visibility,
6030
+ sess.root_session_id
6031
+ FROM ${s}.sessions sess
6032
+ LEFT JOIN ${s}.session_owners so ON so.session_id = sess.session_id
6033
+ LEFT JOIN ${s}.users u ON u.user_id = so.user_id
6034
+ WHERE sess.deleted_at IS NULL
6035
+ ORDER BY sess.updated_at DESC, sess.session_id DESC;
6036
+ END;
6037
+ $$ LANGUAGE plpgsql;
6038
+
6039
+ CREATE FUNCTION ${s}.cms_list_group_sessions(
6040
+ p_group_id TEXT
6041
+ ) RETURNS TABLE (
6042
+ session_id TEXT,
6043
+ orchestration_id TEXT,
6044
+ title TEXT,
6045
+ title_locked BOOLEAN,
6046
+ state TEXT,
6047
+ model TEXT,
6048
+ reasoning_effort TEXT,
6049
+ group_id TEXT,
6050
+ short_summary TEXT,
6051
+ summary_state JSONB,
6052
+ summary_updated_at TIMESTAMPTZ,
6053
+ created_at TIMESTAMPTZ,
6054
+ updated_at TIMESTAMPTZ,
6055
+ last_active_at TIMESTAMPTZ,
6056
+ deleted_at TIMESTAMPTZ,
6057
+ current_iteration INTEGER,
6058
+ last_error TEXT,
6059
+ parent_session_id TEXT,
6060
+ wait_reason TEXT,
6061
+ is_system BOOLEAN,
6062
+ agent_id TEXT,
6063
+ splash TEXT,
6064
+ owner_provider TEXT,
6065
+ owner_subject TEXT,
6066
+ owner_email TEXT,
6067
+ owner_display_name TEXT,
6068
+ splash_mobile TEXT,
6069
+ visibility TEXT,
6070
+ root_session_id TEXT
6071
+ ) AS $$
6072
+ BEGIN
6073
+ RETURN QUERY
6074
+ SELECT * FROM ${s}.cms_list_sessions() sess
6075
+ WHERE sess.group_id = p_group_id
6076
+ ORDER BY sess.updated_at DESC, sess.session_id DESC;
6077
+ END;
6078
+ $$ LANGUAGE plpgsql;
6079
+
6080
+ -- cms_list_sessions_page: viewer-aware. p_viewer_provider NULL = unfiltered
6081
+ -- (admin / internal callers). Non-NULL viewer sees: own trees, trees shared
6082
+ -- deployment-wide or granted to them, and system sessions when
6083
+ -- p_viewer_system_visible.
6084
+ DROP FUNCTION IF EXISTS ${s}.cms_list_sessions_page(INT, TIMESTAMPTZ, TEXT, BOOL);
6085
+ CREATE FUNCTION ${s}.cms_list_sessions_page(
6086
+ p_limit INT DEFAULT 51,
6087
+ p_cursor_updated_at TIMESTAMPTZ DEFAULT NULL,
6088
+ p_cursor_session_id TEXT DEFAULT NULL,
6089
+ p_include_deleted BOOL DEFAULT FALSE,
6090
+ p_viewer_provider TEXT DEFAULT NULL,
6091
+ p_viewer_subject TEXT DEFAULT NULL,
6092
+ p_viewer_system_visible BOOL DEFAULT TRUE
6093
+ ) RETURNS TABLE (
6094
+ session_id TEXT,
6095
+ orchestration_id TEXT,
6096
+ title TEXT,
6097
+ title_locked BOOLEAN,
6098
+ state TEXT,
6099
+ model TEXT,
6100
+ reasoning_effort TEXT,
6101
+ group_id TEXT,
6102
+ short_summary TEXT,
6103
+ summary_state JSONB,
6104
+ summary_updated_at TIMESTAMPTZ,
6105
+ created_at TIMESTAMPTZ,
6106
+ updated_at TIMESTAMPTZ,
6107
+ last_active_at TIMESTAMPTZ,
6108
+ deleted_at TIMESTAMPTZ,
6109
+ current_iteration INTEGER,
6110
+ last_error TEXT,
6111
+ parent_session_id TEXT,
6112
+ wait_reason TEXT,
6113
+ is_system BOOLEAN,
6114
+ agent_id TEXT,
6115
+ splash TEXT,
6116
+ owner_provider TEXT,
6117
+ owner_subject TEXT,
6118
+ owner_email TEXT,
6119
+ owner_display_name TEXT,
6120
+ splash_mobile TEXT,
6121
+ visibility TEXT,
6122
+ root_session_id TEXT
6123
+ ) AS $$
6124
+ DECLARE
6125
+ v_limit INT := GREATEST(1, LEAST(COALESCE(p_limit, 51), 201));
6126
+ BEGIN
6127
+ RETURN QUERY
6128
+ SELECT
6129
+ sess.session_id,
6130
+ sess.orchestration_id,
6131
+ sess.title,
6132
+ sess.title_locked,
6133
+ sess.state,
6134
+ sess.model,
6135
+ sess.reasoning_effort,
6136
+ sess.group_id,
6137
+ sess.short_summary,
6138
+ sess.summary_state,
6139
+ sess.summary_updated_at,
6140
+ sess.created_at,
6141
+ sess.updated_at,
6142
+ sess.last_active_at,
6143
+ sess.deleted_at,
6144
+ sess.current_iteration,
6145
+ sess.last_error,
6146
+ sess.parent_session_id,
6147
+ sess.wait_reason,
6148
+ sess.is_system,
6149
+ sess.agent_id,
6150
+ sess.splash,
6151
+ u.provider AS owner_provider,
6152
+ u.subject AS owner_subject,
6153
+ u.email AS owner_email,
6154
+ u.display_name AS owner_display_name,
6155
+ sess.splash_mobile,
6156
+ sess.visibility,
6157
+ sess.root_session_id
6158
+ FROM ${s}.sessions sess
6159
+ LEFT JOIN ${s}.session_owners so ON so.session_id = sess.session_id
6160
+ LEFT JOIN ${s}.users u ON u.user_id = so.user_id
6161
+ WHERE
6162
+ (p_include_deleted OR sess.deleted_at IS NULL)
6163
+ AND (
6164
+ p_cursor_updated_at IS NULL
6165
+ OR sess.updated_at < p_cursor_updated_at
6166
+ OR (sess.updated_at = p_cursor_updated_at AND sess.session_id < p_cursor_session_id)
6167
+ )
6168
+ AND (
6169
+ p_viewer_provider IS NULL
6170
+ OR EXISTS (
6171
+ SELECT 1
6172
+ FROM ${s}.sessions r
6173
+ LEFT JOIN ${s}.session_owners rso ON rso.session_id = r.session_id
6174
+ LEFT JOIN ${s}.users ru ON ru.user_id = rso.user_id
6175
+ WHERE r.session_id = COALESCE(sess.root_session_id, sess.session_id)
6176
+ AND (
6177
+ (r.is_system AND p_viewer_system_visible)
6178
+ OR (ru.provider = BTRIM(p_viewer_provider) AND ru.subject = BTRIM(p_viewer_subject))
6179
+ OR COALESCE(r.visibility, 'private') IN ('shared_read', 'shared_write')
6180
+ OR EXISTS (
6181
+ SELECT 1 FROM ${s}.session_shares sh
6182
+ JOIN ${s}.users vu ON vu.user_id = sh.user_id
6183
+ WHERE sh.session_id = r.session_id
6184
+ AND vu.provider = BTRIM(p_viewer_provider)
6185
+ AND vu.subject = BTRIM(p_viewer_subject)
6186
+ )
6187
+ )
6188
+ )
6189
+ )
6190
+ ORDER BY sess.updated_at DESC, sess.session_id DESC
6191
+ LIMIT v_limit;
6192
+ END;
6193
+ $$ LANGUAGE plpgsql;
6194
+
6195
+ -- cms_list_sessions_visible: non-paged viewer-scoped listing, same shape and
6196
+ -- predicate as the paged variant. Used by the plain listSessions op for
6197
+ -- non-admin callers.
6198
+ DROP FUNCTION IF EXISTS ${s}.cms_list_sessions_visible(TEXT, TEXT, BOOL);
6199
+ CREATE FUNCTION ${s}.cms_list_sessions_visible(
6200
+ p_viewer_provider TEXT,
6201
+ p_viewer_subject TEXT,
6202
+ p_viewer_system_visible BOOL
6203
+ ) RETURNS TABLE (
6204
+ session_id TEXT,
6205
+ orchestration_id TEXT,
6206
+ title TEXT,
6207
+ title_locked BOOLEAN,
6208
+ state TEXT,
6209
+ model TEXT,
6210
+ reasoning_effort TEXT,
6211
+ group_id TEXT,
6212
+ short_summary TEXT,
6213
+ summary_state JSONB,
6214
+ summary_updated_at TIMESTAMPTZ,
6215
+ created_at TIMESTAMPTZ,
6216
+ updated_at TIMESTAMPTZ,
6217
+ last_active_at TIMESTAMPTZ,
6218
+ deleted_at TIMESTAMPTZ,
6219
+ current_iteration INTEGER,
6220
+ last_error TEXT,
6221
+ parent_session_id TEXT,
6222
+ wait_reason TEXT,
6223
+ is_system BOOLEAN,
6224
+ agent_id TEXT,
6225
+ splash TEXT,
6226
+ owner_provider TEXT,
6227
+ owner_subject TEXT,
6228
+ owner_email TEXT,
6229
+ owner_display_name TEXT,
6230
+ splash_mobile TEXT,
6231
+ visibility TEXT,
6232
+ root_session_id TEXT
6233
+ ) AS $$
6234
+ BEGIN
6235
+ RETURN QUERY
6236
+ SELECT * FROM ${s}.cms_list_sessions() sess
6237
+ WHERE EXISTS (
6238
+ SELECT 1
6239
+ FROM ${s}.sessions r
6240
+ LEFT JOIN ${s}.session_owners rso ON rso.session_id = r.session_id
6241
+ LEFT JOIN ${s}.users ru ON ru.user_id = rso.user_id
6242
+ WHERE r.session_id = COALESCE(sess.root_session_id, sess.session_id)
6243
+ AND (
6244
+ (r.is_system AND p_viewer_system_visible)
6245
+ OR (ru.provider = BTRIM(p_viewer_provider) AND ru.subject = BTRIM(p_viewer_subject))
6246
+ OR COALESCE(r.visibility, 'private') IN ('shared_read', 'shared_write')
6247
+ OR EXISTS (
6248
+ SELECT 1 FROM ${s}.session_shares sh
6249
+ JOIN ${s}.users vu ON vu.user_id = sh.user_id
6250
+ WHERE sh.session_id = r.session_id
6251
+ AND vu.provider = BTRIM(p_viewer_provider)
6252
+ AND vu.subject = BTRIM(p_viewer_subject)
6253
+ )
6254
+ )
6255
+ )
6256
+ ORDER BY sess.updated_at DESC, sess.session_id DESC;
6257
+ END;
6258
+ $$ LANGUAGE plpgsql;
6259
+ `;
6260
+ return [step_columns, step_backfill, step_drop_invalid_index, step_index, step_tables_and_functions];
6261
+ }
5133
6262
  //# sourceMappingURL=cms-migrations.js.map