@opengeni/db 0.7.3 → 0.10.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/{chunk-YFQ7SGE4.js → chunk-BMFDXFPA.js} +23 -1
  2. package/dist/chunk-BMFDXFPA.js.map +1 -0
  3. package/dist/chunk-KW526IJA.js +127 -0
  4. package/dist/chunk-KW526IJA.js.map +1 -0
  5. package/dist/{chunk-B22X3IEZ.js → chunk-P6PKXY5W.js} +604 -32
  6. package/dist/chunk-P6PKXY5W.js.map +1 -0
  7. package/dist/index.d.ts +4 -3
  8. package/dist/index.js +7408 -2276
  9. package/dist/index.js.map +1 -1
  10. package/dist/migrate.d.ts +29 -4
  11. package/dist/migrate.js +3 -1
  12. package/dist/provision-roles.d.ts +1122 -73
  13. package/dist/provision-roles.js +1 -1
  14. package/dist/{schema-BN5mB9xZ.d.ts → schema-CqkzrBRS.d.ts} +6967 -4457
  15. package/dist/schema.d.ts +1 -1
  16. package/dist/schema.js +19 -1
  17. package/drizzle/0053_codex_credential_leases.sql +2 -2
  18. package/drizzle/0057_durable_queue_control.sql +1 -1
  19. package/drizzle/0061_session_workflow_wake_outbox.sql +1 -1
  20. package/drizzle/0062_session_list_snapshot_reaper.sql +1 -1
  21. package/drizzle/0063_session_control_mega_foundation.sql +1 -1
  22. package/drizzle/0064_rotation_strategy_sharded_backfill.sql +15 -0
  23. package/drizzle/0065_codex_subscription_overview.sql +168 -0
  24. package/drizzle/0065_session_attempt_quiescence.sql +26 -0
  25. package/drizzle/0065_session_tool_policy.sql +38 -0
  26. package/drizzle/0066_session_interruption_attempt_lookup.sql +4 -0
  27. package/drizzle/0067_session_event_payload_bounds.sql +209 -0
  28. package/drizzle/0068_workspace_control_event_bounds.sql +134 -0
  29. package/drizzle/0069_session_event_history_backfill.sql +32 -0
  30. package/drizzle/0070_session_event_type_sequence_lookup.sql +4 -0
  31. package/drizzle/0071_session_event_monitoring_tail.sql +10 -0
  32. package/drizzle/0072_sessions_workspace_created_id_idx.sql +4 -0
  33. package/drizzle/0073_sessions_workspace_updated_id_idx.sql +4 -0
  34. package/drizzle/0074_session_activity_revisions.sql +72 -0
  35. package/drizzle/0075_sessions_workspace_activity_revision_idx.sql +4 -0
  36. package/drizzle/0076_session_workflow_wake_acl.sql +13 -0
  37. package/drizzle/0077_session_attempt_latest_lookup.sql +4 -0
  38. package/drizzle/0094_quarantine_credential_bearing_catalog_urls.sql +51 -0
  39. package/drizzle/0095_github_existing_installations.sql +84 -0
  40. package/drizzle/0096_session_turn_initiators.sql +97 -0
  41. package/drizzle/0097_host_export_outbox.sql +1220 -0
  42. package/drizzle/0098_usage_events_workspace_session_idx.sql +4 -0
  43. package/drizzle/0099_session_human_input_attempt_owner_index.sql +6 -0
  44. package/drizzle/0100_session_human_input_requests.sql +89 -0
  45. package/drizzle/0101_session_mcp_connection_refs.sql +30 -0
  46. package/drizzle/0102_session_command_receipt_service_actor.sql +16 -0
  47. package/drizzle/0103_host_export_root_session.sql +166 -0
  48. package/drizzle/0104_host_export_root_session_backfill.sql +27 -0
  49. package/drizzle/0105_session_turn_instructions.sql +9 -0
  50. package/drizzle/0106_session_attempt_mcp_approval_policies.sql +29 -0
  51. package/drizzle/0107_host_export_lineage_contract.sql +381 -0
  52. package/drizzle/0108_fence_invalidated_warming_epochs.sql +76 -0
  53. package/package.json +5 -4
  54. package/src/codex-token-resolver.ts +175 -14
  55. package/src/connection-token-resolver.ts +428 -119
  56. package/src/event-payload-sanitizer.ts +88 -20
  57. package/src/index.ts +7208 -1273
  58. package/src/memory-domain.ts +1 -1
  59. package/src/migrate.ts +86 -23
  60. package/src/persistence-errors.ts +252 -0
  61. package/src/provision-roles.ts +42 -0
  62. package/src/schema.ts +658 -34
  63. package/src/session-control.ts +520 -38
  64. package/src/session-queue-commands.ts +385 -61
  65. package/src/session-tool-call-settlement.ts +58 -7
  66. package/src/turn-initiator.ts +155 -0
  67. package/dist/chunk-7LDU7F5P.js +0 -80
  68. package/dist/chunk-7LDU7F5P.js.map +0 -1
  69. package/dist/chunk-B22X3IEZ.js.map +0 -1
  70. package/dist/chunk-YFQ7SGE4.js.map +0 -1
@@ -1,5 +1,11 @@
1
1
  import { createHash } from "node:crypto";
2
- import { and, eq, sql } from "drizzle-orm";
2
+ import {
3
+ boundWorkspaceControlEvent,
4
+ workspaceControlUtf8Bytes,
5
+ type SessionMcpApprovalPolicy,
6
+ type TurnInitiatorContext,
7
+ } from "@opengeni/contracts";
8
+ import { and, eq, inArray, sql } from "drizzle-orm";
3
9
  import type { Database } from "./index";
4
10
  import * as schema from "./schema";
5
11
 
@@ -9,6 +15,12 @@ export type WorkspaceControlLockMode = "share" | "update";
9
15
  export type EffectiveControlState = "active" | "paused";
10
16
  export type SessionCommandActor =
11
17
  | { type: "human" | "operator"; subjectId: string }
18
+ | {
19
+ type: "service";
20
+ subjectId: string;
21
+ subjectLabel?: string;
22
+ context?: TurnInitiatorContext;
23
+ }
12
24
  | {
13
25
  type: "agent_attempt";
14
26
  attemptId: string;
@@ -54,7 +66,44 @@ export type EffectiveSessionControl = {
54
66
  blockers: EffectiveControlBlocker[];
55
67
  resumeOptions: EffectiveControlResumeOption[];
56
68
  override: { rootSessionId: string; revision: number } | null;
57
- settlement: { state: "stopping"; attemptCount: number } | null;
69
+ settlement: {
70
+ state: "stopping";
71
+ attemptCount: number;
72
+ interruptionPendingCount: number;
73
+ quiescencePendingCount: number;
74
+ } | null;
75
+ };
76
+
77
+ type SettlementAttemptCounts = {
78
+ attemptCount: number;
79
+ interruptionPendingCount: number;
80
+ quiescencePendingCount: number;
81
+ };
82
+
83
+ const NO_SETTLEMENT_ATTEMPTS: SettlementAttemptCounts = {
84
+ attemptCount: 0,
85
+ interruptionPendingCount: 0,
86
+ quiescencePendingCount: 0,
87
+ };
88
+
89
+ export const SESSION_DISCOVERY_CONTROL_TITLE_MAX_CHARS = 200;
90
+ export const SESSION_DISCOVERY_CONTROL_TARGET_LIMIT = 100;
91
+
92
+ /**
93
+ * Purpose-built control projection for session discovery. Full blocker evidence,
94
+ * actors/reasons, resume options, etags, and settlement state remain available
95
+ * from the ordinary session detail APIs; this shape is intentionally only what
96
+ * `sessions_list` renders.
97
+ */
98
+ export type SessionDiscoveryControl = {
99
+ state: EffectiveControlState;
100
+ primaryBlocker: {
101
+ kind: "session" | "workspace";
102
+ sessionId?: string;
103
+ displayName: string;
104
+ displayNameOriginalChars: number;
105
+ } | null;
106
+ additionalBlockerCount: number;
58
107
  };
59
108
 
60
109
  export function serializeEffectiveSessionControl(control: EffectiveSessionControl) {
@@ -79,7 +128,7 @@ export function serializeEffectiveSessionControl(control: EffectiveSessionContro
79
128
  };
80
129
  }
81
130
 
82
- type WorkspaceControlRow = {
131
+ export type WorkspaceControlRow = {
83
132
  workspaceId: string;
84
133
  accountId: string;
85
134
  revision: number | string;
@@ -156,17 +205,18 @@ export async function assertAgentCommandAuthorityInTransaction(
156
205
  action: "pause" | "resume" | "steer" | "message";
157
206
  },
158
207
  ): Promise<void> {
159
- const lockedSessions = await db
160
- .select({ id: schema.sessions.id })
161
- .from(schema.sessions)
162
- .where(
163
- and(
164
- eq(schema.sessions.workspaceId, input.workspaceId),
165
- sql`${schema.sessions.id} in (${input.actor.sessionId}::uuid, ${input.targetSessionId}::uuid)`,
166
- ),
167
- )
168
- .orderBy(schema.sessions.id)
169
- .for("update");
208
+ // Every command caller establishes the control/workspace prefix first.
209
+ // Reusing the event-write helper here keeps cross-session actor authority on
210
+ // the same UUID-ordered session -> exact turn -> exact attempt suffix.
211
+ const authorityLocks = await lockSessionEventWriteRows(db, {
212
+ workspaceId: input.workspaceId,
213
+ controlLock: "already_locked",
214
+ workspaceLock: "already_locked",
215
+ sessionIds: [input.actor.sessionId, input.targetSessionId],
216
+ turnIds: [input.actor.turnId],
217
+ attemptIds: [input.actor.attemptId],
218
+ });
219
+ const lockedSessions = authorityLocks.sessions;
170
220
  if (!lockedSessions.some((row) => row.id === input.actor.sessionId)) {
171
221
  throw new AgentCommandAuthorityError("CALLER_STALE", "The calling session no longer exists");
172
222
  }
@@ -207,7 +257,6 @@ export async function assertAgentCommandAuthorityInTransaction(
207
257
  on session.workspace_id = attempt.workspace_id and session.id = attempt.session_id
208
258
  where attempt.workspace_id = ${input.workspaceId}
209
259
  and attempt.id = ${input.actor.attemptId}
210
- for update of attempt, turn
211
260
  `);
212
261
  const caller = rows[0];
213
262
  if (
@@ -337,6 +386,120 @@ export async function lockWorkspaceInferenceControl(
337
386
  return row;
338
387
  }
339
388
 
389
+ export type SessionEventWriteLockInput = {
390
+ workspaceId: string;
391
+ /**
392
+ * Control-aware writes take this lock first. Callers that already hold the
393
+ * workspace control row (for example a Pause mutation under FOR UPDATE) say
394
+ * `already_locked`; ordinary audit/title appends explicitly use `none`.
395
+ */
396
+ controlLock: WorkspaceControlLockMode | "already_locked" | "none";
397
+ /** Used only when a staged caller already established the workspace prefix. */
398
+ workspaceLock?: "key_share" | "already_locked";
399
+ sessionIds?: string[];
400
+ turnIds?: string[];
401
+ attemptIds?: string[];
402
+ };
403
+
404
+ export type SessionEventWriteLocks = {
405
+ control: WorkspaceControlRow | null;
406
+ workspace: typeof schema.workspaces.$inferSelect | null;
407
+ sessions: Array<typeof schema.sessions.$inferSelect>;
408
+ turns: Array<typeof schema.sessionTurns.$inferSelect>;
409
+ attempts: Array<typeof schema.sessionTurnAttempts.$inferSelect>;
410
+ };
411
+
412
+ /**
413
+ * Establish the one canonical lock prefix for every `session_events` writer:
414
+ *
415
+ * workspace_inference_controls (when control-aware)
416
+ * -> actual workspaces row FOR KEY SHARE
417
+ * -> session rows FOR UPDATE, UUID ordered
418
+ * -> exact turn rows FOR UPDATE, UUID ordered
419
+ * -> exact attempt rows FOR UPDATE, UUID ordered
420
+ *
421
+ * `FOR KEY SHARE` is deliberate. Event inserts need the workspace key to remain
422
+ * stable for their FK, but they do not mutate the workspace. The old generic
423
+ * `FOR UPDATE` lock serialized unrelated sessions and inverted the activity
424
+ * path's session -> implicit workspace-FK edge.
425
+ *
426
+ * Complex lifecycle transactions may acquire allocator/control locks before
427
+ * this helper and may discover exact turn IDs only after locking the session.
428
+ * They use the explicit `already_locked` stages, while retaining the same
429
+ * monotonic table order. New event writers should prefer one complete call.
430
+ */
431
+ export async function lockSessionEventWriteRows(
432
+ db: Database,
433
+ input: SessionEventWriteLockInput,
434
+ ): Promise<SessionEventWriteLocks> {
435
+ const controlLock = input.controlLock;
436
+ const control =
437
+ controlLock === "share" || controlLock === "update"
438
+ ? await lockWorkspaceInferenceControl(db, input.workspaceId, controlLock)
439
+ : null;
440
+
441
+ let workspace: typeof schema.workspaces.$inferSelect | null = null;
442
+ if ((input.workspaceLock ?? "key_share") === "key_share") {
443
+ const [lockedWorkspace] = await db
444
+ .select()
445
+ .from(schema.workspaces)
446
+ .where(eq(schema.workspaces.id, input.workspaceId))
447
+ .for("key share")
448
+ .limit(1);
449
+ workspace = lockedWorkspace ?? null;
450
+ }
451
+
452
+ const sessionIds = [...new Set(input.sessionIds ?? [])].sort();
453
+ const sessions =
454
+ sessionIds.length > 0
455
+ ? await db
456
+ .select()
457
+ .from(schema.sessions)
458
+ .where(
459
+ and(
460
+ eq(schema.sessions.workspaceId, input.workspaceId),
461
+ inArray(schema.sessions.id, sessionIds),
462
+ ),
463
+ )
464
+ .orderBy(schema.sessions.id)
465
+ .for("update")
466
+ : [];
467
+
468
+ const turnIds = [...new Set(input.turnIds ?? [])].sort();
469
+ const turns =
470
+ turnIds.length > 0
471
+ ? await db
472
+ .select()
473
+ .from(schema.sessionTurns)
474
+ .where(
475
+ and(
476
+ eq(schema.sessionTurns.workspaceId, input.workspaceId),
477
+ inArray(schema.sessionTurns.id, turnIds),
478
+ ),
479
+ )
480
+ .orderBy(schema.sessionTurns.id)
481
+ .for("update")
482
+ : [];
483
+
484
+ const attemptIds = [...new Set(input.attemptIds ?? [])].sort();
485
+ const attempts =
486
+ attemptIds.length > 0
487
+ ? await db
488
+ .select()
489
+ .from(schema.sessionTurnAttempts)
490
+ .where(
491
+ and(
492
+ eq(schema.sessionTurnAttempts.workspaceId, input.workspaceId),
493
+ inArray(schema.sessionTurnAttempts.id, attemptIds),
494
+ ),
495
+ )
496
+ .orderBy(schema.sessionTurnAttempts.id)
497
+ .for("update")
498
+ : [];
499
+
500
+ return { control, workspace, sessions, turns, attempts };
501
+ }
502
+
340
503
  export async function registerSessionTurnAttemptClaim(
341
504
  db: Database,
342
505
  input: {
@@ -350,6 +513,7 @@ export async function registerSessionTurnAttemptClaim(
350
513
  temporalWorkflowRunId: string;
351
514
  temporalActivityId: string;
352
515
  verifiedControlRevision: number;
516
+ mcpApprovalPolicies: Record<string, SessionMcpApprovalPolicy>;
353
517
  },
354
518
  ): Promise<typeof schema.sessionTurnAttempts.$inferSelect> {
355
519
  const [inserted] = await db
@@ -643,7 +807,7 @@ function projectEffectiveControl(
643
807
  workspace: WorkspaceControlRow,
644
808
  targetId: string,
645
809
  rows: AncestryRow[],
646
- stoppingAttempts: number,
810
+ settlementAttempts: SettlementAttemptCounts,
647
811
  ): EffectiveSessionControl {
648
812
  assertCompleteAncestry(targetId, rows);
649
813
  const workspaceRevision = asSafeRevision(workspace.revision, "workspace control revision")!;
@@ -775,30 +939,61 @@ function projectEffectiveControl(
775
939
  rootSessionId: override.row.sessionId,
776
940
  revision: override.overrideRevision,
777
941
  },
778
- settlement: stoppingAttempts > 0 ? { state: "stopping", attemptCount: stoppingAttempts } : null,
942
+ settlement:
943
+ settlementAttempts.attemptCount > 0 ? { state: "stopping", ...settlementAttempts } : null,
779
944
  };
780
945
  }
781
946
 
782
- async function unsettledAttemptCounts(
947
+ async function settlementAttemptCounts(
783
948
  db: Database,
784
949
  workspaceId: string,
785
950
  sessionIds: string[],
786
- ): Promise<Map<string, number>> {
951
+ ): Promise<Map<string, SettlementAttemptCounts>> {
787
952
  const rows = await db.execute<{
788
953
  sessionId: string;
789
- count: number | string;
954
+ attemptCount: number | string;
955
+ interruptionPendingCount: number | string;
956
+ quiescencePendingCount: number | string;
790
957
  }>(sql`
791
958
  with recursive targets(id) as (values ${targetValues(sessionIds)}),
792
959
  interruptions as (
793
- select interruption.session_id, interruption.attempt_id
960
+ select
961
+ interruption.session_id,
962
+ interruption.attempt_id,
963
+ bool_or(interruption.state in ('pending', 'delivered', 'acknowledged'))
964
+ as interruption_pending,
965
+ bool_or(
966
+ interruption.state in ('settled', 'rejected_stale')
967
+ and attempt.quiesced_at is null
968
+ ) as quiescence_pending
794
969
  from ${schema.sessionAttemptInterruptions} interruption
970
+ join ${schema.sessionTurnAttempts} attempt
971
+ on attempt.workspace_id = interruption.workspace_id
972
+ and attempt.id = interruption.attempt_id
795
973
  where interruption.workspace_id = ${workspaceId}
796
- and interruption.state in ('pending', 'delivered', 'acknowledged')
797
- ), interruption_ancestry(session_id, ancestor_id, attempt_id, depth, path) as (
974
+ and (
975
+ interruption.state in ('pending', 'delivered', 'acknowledged')
976
+ or (
977
+ interruption.state in ('settled', 'rejected_stale')
978
+ and attempt.quiesced_at is null
979
+ )
980
+ )
981
+ group by interruption.session_id, interruption.attempt_id
982
+ ), interruption_ancestry(
983
+ session_id,
984
+ ancestor_id,
985
+ attempt_id,
986
+ interruption_pending,
987
+ quiescence_pending,
988
+ depth,
989
+ path
990
+ ) as (
798
991
  select
799
992
  interruption.session_id,
800
993
  interruption.session_id,
801
994
  interruption.attempt_id,
995
+ interruption.interruption_pending,
996
+ interruption.quiescence_pending,
802
997
  0::integer,
803
998
  array[interruption.session_id]::uuid[]
804
999
  from interruptions interruption
@@ -807,6 +1002,8 @@ async function unsettledAttemptCounts(
807
1002
  ancestry.session_id,
808
1003
  current.parent_session_id,
809
1004
  ancestry.attempt_id,
1005
+ ancestry.interruption_pending,
1006
+ ancestry.quiescence_pending,
810
1007
  ancestry.depth + 1,
811
1008
  ancestry.path || current.parent_session_id
812
1009
  from interruption_ancestry ancestry
@@ -816,16 +1013,33 @@ async function unsettledAttemptCounts(
816
1013
  and not current.parent_session_id = any(ancestry.path)
817
1014
  and ancestry.depth < ${SESSION_ANCESTRY_LIMIT}
818
1015
  )
819
- select target.id as "sessionId", count(distinct ancestry.attempt_id)::integer as count
1016
+ select
1017
+ target.id as "sessionId",
1018
+ count(distinct ancestry.attempt_id)::integer as "attemptCount",
1019
+ count(distinct ancestry.attempt_id)
1020
+ filter (where ancestry.interruption_pending)::integer as "interruptionPendingCount",
1021
+ count(distinct ancestry.attempt_id)
1022
+ filter (where ancestry.quiescence_pending)::integer as "quiescencePendingCount"
820
1023
  from targets target
821
1024
  join interruption_ancestry ancestry on ancestry.ancestor_id = target.id
822
1025
  group by target.id
823
1026
  `);
824
1027
  return new Map(
825
- rows.map((row: { sessionId: string; count: number | string }) => [
826
- row.sessionId,
827
- Number(row.count),
828
- ]),
1028
+ rows.map(
1029
+ (row: {
1030
+ sessionId: string;
1031
+ attemptCount: number | string;
1032
+ interruptionPendingCount: number | string;
1033
+ quiescencePendingCount: number | string;
1034
+ }) => [
1035
+ row.sessionId,
1036
+ {
1037
+ attemptCount: Number(row.attemptCount),
1038
+ interruptionPendingCount: Number(row.interruptionPendingCount),
1039
+ quiescencePendingCount: Number(row.quiescencePendingCount),
1040
+ },
1041
+ ],
1042
+ ),
829
1043
  );
830
1044
  }
831
1045
 
@@ -833,14 +1047,25 @@ export async function evaluateSessionControls(
833
1047
  db: Database,
834
1048
  workspaceId: string,
835
1049
  sessionIds: string[],
836
- options: { lock?: WorkspaceControlLockMode } = {},
1050
+ options: {
1051
+ lock?: WorkspaceControlLockMode;
1052
+ /** Reuse a control row already locked before workspace/session/turn rows. */
1053
+ workspaceControl?: WorkspaceControlRow | undefined;
1054
+ } = {},
837
1055
  ): Promise<Map<string, EffectiveSessionControl>> {
838
1056
  const uniqueIds = [...new Set(sessionIds)];
839
1057
  if (uniqueIds.length === 0) {
840
1058
  return new Map();
841
1059
  }
842
- const workspace = await lockWorkspaceInferenceControl(db, workspaceId, options.lock ?? "share");
843
- const stopping = await unsettledAttemptCounts(db, workspaceId, uniqueIds);
1060
+ if (options.workspaceControl && options.workspaceControl.workspaceId !== workspaceId) {
1061
+ throw new SessionControlInvariantError(
1062
+ `Locked workspace control ${options.workspaceControl.workspaceId} does not match ${workspaceId}`,
1063
+ );
1064
+ }
1065
+ const workspace =
1066
+ options.workspaceControl ??
1067
+ (await lockWorkspaceInferenceControl(db, workspaceId, options.lock ?? "share"));
1068
+ const stopping = await settlementAttemptCounts(db, workspaceId, uniqueIds);
844
1069
  const result = new Map<string, EffectiveSessionControl>();
845
1070
  if (uniqueIds.length <= TARGET_PATH_PROJECTION_LIMIT) {
846
1071
  // PostgreSQL's direct target-path plan is substantially faster for the
@@ -859,7 +1084,7 @@ export async function evaluateSessionControls(
859
1084
  workspace,
860
1085
  sessionId,
861
1086
  ancestryByTarget.get(sessionId) ?? [],
862
- stopping.get(sessionId) ?? 0,
1087
+ stopping.get(sessionId) ?? NO_SETTLEMENT_ATTEMPTS,
863
1088
  ),
864
1089
  );
865
1090
  }
@@ -877,18 +1102,236 @@ export async function evaluateSessionControls(
877
1102
  workspace,
878
1103
  sessionId,
879
1104
  ancestryRowsForTarget(sessionId, ancestry),
880
- stopping.get(sessionId) ?? 0,
1105
+ stopping.get(sessionId) ?? NO_SETTLEMENT_ATTEMPTS,
881
1106
  ),
882
1107
  );
883
1108
  }
884
1109
  return result;
885
1110
  }
886
1111
 
1112
+ type SessionDiscoveryControlRow = {
1113
+ targetId: string;
1114
+ ancestryCount: number | string;
1115
+ reachedRoot: boolean;
1116
+ maxDepth: number | string | null;
1117
+ blockerCount: number | string;
1118
+ primaryKind: "session" | "workspace" | null;
1119
+ primarySessionId: string | null;
1120
+ primaryDisplayName: string | null;
1121
+ primaryDisplayNameOriginalChars: number | string | null;
1122
+ };
1123
+
1124
+ /**
1125
+ * Return one compact aggregate row per target for `sessions_list`.
1126
+ *
1127
+ * Unlike `evaluateSessionControls`, the database boundary never returns a row
1128
+ * per blocker/ancestor, nor does application code construct blocker or resume
1129
+ * option arrays. The recursive walk carries no growing visited-path array: a
1130
+ * missing ancestor stops below the limit, while a cycle cannot reach a root and
1131
+ * therefore runs into the hard `SESSION_ANCESTRY_LIMIT`. The externally
1132
+ * supplied target set is capped by `SESSION_DISCOVERY_CONTROL_TARGET_LIMIT`.
1133
+ */
1134
+ export async function evaluateSessionDiscoveryControls(
1135
+ db: Database,
1136
+ workspaceId: string,
1137
+ sessionIds: string[],
1138
+ ): Promise<Map<string, SessionDiscoveryControl>> {
1139
+ const uniqueIds = [...new Set(sessionIds)];
1140
+ if (uniqueIds.length === 0) return new Map();
1141
+ if (uniqueIds.length > SESSION_DISCOVERY_CONTROL_TARGET_LIMIT) {
1142
+ throw new SessionControlInvariantError(
1143
+ `Session discovery control projection exceeds ${SESSION_DISCOVERY_CONTROL_TARGET_LIMIT} targets`,
1144
+ );
1145
+ }
1146
+
1147
+ const workspace = await lockWorkspaceInferenceControl(db, workspaceId, "share");
1148
+ const workspacePauseRevision = asSafeRevision(
1149
+ workspace.workspacePauseRevision,
1150
+ "workspace pause revision",
1151
+ );
1152
+ if (workspace.workspaceState === "paused" && workspacePauseRevision === null) {
1153
+ throw new SessionControlInvariantError("Paused workspace is missing its pause revision");
1154
+ }
1155
+
1156
+ const rows = await db.execute<SessionDiscoveryControlRow>(sql`
1157
+ with recursive targets(id) as (values ${targetValues(uniqueIds)}),
1158
+ ancestry as (
1159
+ select
1160
+ target.id as target_id,
1161
+ session.id as session_id,
1162
+ session.parent_session_id,
1163
+ left(coalesce(nullif(btrim(session.title), ''), 'Untitled session'), ${SESSION_DISCOVERY_CONTROL_TITLE_MAX_CHARS}) as display_name,
1164
+ char_length(coalesce(nullif(btrim(session.title), ''), 'Untitled session'))::integer as display_name_original_chars,
1165
+ session.direct_control_state,
1166
+ session.direct_pause_revision,
1167
+ session.subtree_run_override_revision,
1168
+ 0::integer as depth
1169
+ from targets target
1170
+ join ${schema.sessions} session
1171
+ on session.workspace_id = ${workspaceId} and session.id = target.id
1172
+ union all
1173
+ select
1174
+ child.target_id,
1175
+ parent.id,
1176
+ parent.parent_session_id,
1177
+ left(coalesce(nullif(btrim(parent.title), ''), 'Untitled session'), ${SESSION_DISCOVERY_CONTROL_TITLE_MAX_CHARS}),
1178
+ char_length(coalesce(nullif(btrim(parent.title), ''), 'Untitled session'))::integer,
1179
+ parent.direct_control_state,
1180
+ parent.direct_pause_revision,
1181
+ parent.subtree_run_override_revision,
1182
+ child.depth + 1
1183
+ from ancestry child
1184
+ join ${schema.sessions} parent
1185
+ on parent.workspace_id = ${workspaceId} and parent.id = child.parent_session_id
1186
+ where child.parent_session_id is not null
1187
+ and child.depth < ${SESSION_ANCESTRY_LIMIT}
1188
+ ),
1189
+ path as (
1190
+ select
1191
+ ancestry.*,
1192
+ max(ancestry.subtree_run_override_revision) over (
1193
+ partition by ancestry.target_id
1194
+ order by ancestry.depth
1195
+ rows between unbounded preceding and 1 preceding
1196
+ ) as descendant_override_revision
1197
+ from ancestry
1198
+ ),
1199
+ session_blockers as (
1200
+ select
1201
+ path.target_id,
1202
+ 'session'::text as kind,
1203
+ path.session_id,
1204
+ path.display_name,
1205
+ path.display_name_original_chars,
1206
+ path.depth
1207
+ from path
1208
+ where path.direct_control_state = 'paused'
1209
+ and path.direct_pause_revision is not null
1210
+ and (
1211
+ path.descendant_override_revision is null
1212
+ or path.descendant_override_revision <= path.direct_pause_revision
1213
+ )
1214
+ ),
1215
+ workspace_blockers as (
1216
+ select
1217
+ target.id as target_id,
1218
+ 'workspace'::text as kind,
1219
+ null::uuid as session_id,
1220
+ 'Workspace'::text as display_name,
1221
+ 9::integer as display_name_original_chars,
1222
+ ${SESSION_ANCESTRY_LIMIT + 1}::integer as depth
1223
+ from targets target
1224
+ where ${workspace.workspaceState === "paused"}
1225
+ and not exists (
1226
+ select 1
1227
+ from path
1228
+ where path.target_id = target.id
1229
+ and path.subtree_run_override_revision is not null
1230
+ and path.subtree_run_override_revision > ${workspacePauseRevision}
1231
+ )
1232
+ ),
1233
+ blockers as (
1234
+ select * from session_blockers
1235
+ union all
1236
+ select * from workspace_blockers
1237
+ ),
1238
+ primary_blockers as (
1239
+ select distinct on (blockers.target_id)
1240
+ blockers.target_id,
1241
+ blockers.kind,
1242
+ blockers.session_id,
1243
+ blockers.display_name,
1244
+ blockers.display_name_original_chars
1245
+ from blockers
1246
+ order by blockers.target_id, blockers.depth
1247
+ ),
1248
+ blocker_counts as (
1249
+ select blockers.target_id, count(*)::integer as blocker_count
1250
+ from blockers
1251
+ group by blockers.target_id
1252
+ ),
1253
+ ancestry_metadata as (
1254
+ select
1255
+ ancestry.target_id,
1256
+ count(*)::integer as ancestry_count,
1257
+ bool_or(ancestry.parent_session_id is null) as reached_root,
1258
+ max(ancestry.depth)::integer as max_depth
1259
+ from ancestry
1260
+ group by ancestry.target_id
1261
+ )
1262
+ select
1263
+ target.id as "targetId",
1264
+ coalesce(metadata.ancestry_count, 0)::integer as "ancestryCount",
1265
+ coalesce(metadata.reached_root, false) as "reachedRoot",
1266
+ metadata.max_depth as "maxDepth",
1267
+ coalesce(counts.blocker_count, 0)::integer as "blockerCount",
1268
+ primary_blocker.kind as "primaryKind",
1269
+ primary_blocker.session_id as "primarySessionId",
1270
+ primary_blocker.display_name as "primaryDisplayName",
1271
+ primary_blocker.display_name_original_chars as "primaryDisplayNameOriginalChars"
1272
+ from targets target
1273
+ left join ancestry_metadata metadata on metadata.target_id = target.id
1274
+ left join blocker_counts counts on counts.target_id = target.id
1275
+ left join primary_blockers primary_blocker on primary_blocker.target_id = target.id
1276
+ order by target.id
1277
+ `);
1278
+
1279
+ const result = new Map<string, SessionDiscoveryControl>();
1280
+ for (const row of rows) {
1281
+ const ancestryCount = Number(row.ancestryCount);
1282
+ const maxDepth = row.maxDepth === null ? null : Number(row.maxDepth);
1283
+ if (ancestryCount === 0) {
1284
+ throw new SessionControlInvariantError(
1285
+ `Session ${row.targetId} does not exist in its workspace`,
1286
+ );
1287
+ }
1288
+ if (!row.reachedRoot) {
1289
+ throw new SessionControlInvariantError(
1290
+ maxDepth !== null && maxDepth >= SESSION_ANCESTRY_LIMIT
1291
+ ? `Session ${row.targetId} ancestry exceeds ${SESSION_ANCESTRY_LIMIT}`
1292
+ : `Session ${row.targetId} has a missing ancestor`,
1293
+ );
1294
+ }
1295
+
1296
+ const blockerCount = Number(row.blockerCount);
1297
+ const primaryBlocker = row.primaryKind
1298
+ ? {
1299
+ kind: row.primaryKind,
1300
+ ...(row.primarySessionId ? { sessionId: row.primarySessionId } : {}),
1301
+ displayName:
1302
+ row.primaryDisplayName ??
1303
+ (row.primaryKind === "workspace" ? "Workspace" : "Untitled session"),
1304
+ displayNameOriginalChars: Number(
1305
+ row.primaryDisplayNameOriginalChars ??
1306
+ (row.primaryKind === "workspace" ? 9 : "Untitled session".length),
1307
+ ),
1308
+ }
1309
+ : null;
1310
+ if (blockerCount > 0 !== (primaryBlocker !== null)) {
1311
+ throw new SessionControlInvariantError(
1312
+ `Session ${row.targetId} discovery blocker aggregate is inconsistent`,
1313
+ );
1314
+ }
1315
+ result.set(row.targetId, {
1316
+ state: blockerCount > 0 ? "paused" : "active",
1317
+ primaryBlocker,
1318
+ additionalBlockerCount: Math.max(0, blockerCount - 1),
1319
+ });
1320
+ }
1321
+ if (result.size !== uniqueIds.length) {
1322
+ throw new SessionControlInvariantError("Session discovery control projection is incomplete");
1323
+ }
1324
+ return result;
1325
+ }
1326
+
887
1327
  export async function evaluateSessionControl(
888
1328
  db: Database,
889
1329
  workspaceId: string,
890
1330
  sessionId: string,
891
- options: { lock?: WorkspaceControlLockMode } = {},
1331
+ options: {
1332
+ lock?: WorkspaceControlLockMode;
1333
+ workspaceControl?: WorkspaceControlRow | undefined;
1334
+ } = {},
892
1335
  ): Promise<EffectiveSessionControl> {
893
1336
  return (await evaluateSessionControls(db, workspaceId, [sessionId], options)).get(sessionId)!;
894
1337
  }
@@ -1332,6 +1775,16 @@ export async function mutateSessionControlInTransaction(
1332
1775
  },
1333
1776
  ): Promise<SessionControlMutationResult> {
1334
1777
  const workspace = await lockWorkspaceInferenceControl(db, input.workspaceId, "update");
1778
+ await lockSessionEventWriteRows(db, {
1779
+ workspaceId: input.workspaceId,
1780
+ controlLock: "already_locked",
1781
+ sessionIds:
1782
+ input.actor.type === "agent_attempt"
1783
+ ? [input.actor.sessionId, input.sessionId]
1784
+ : [input.sessionId],
1785
+ turnIds: input.actor.type === "agent_attempt" ? [input.actor.turnId] : [],
1786
+ attemptIds: input.actor.type === "agent_attempt" ? [input.actor.attemptId] : [],
1787
+ });
1335
1788
  const hash = canonicalSessionCommandHash({
1336
1789
  action: input.action,
1337
1790
  reason: input.reason ?? null,
@@ -1361,7 +1814,7 @@ export async function mutateSessionControlInTransaction(
1361
1814
  return {
1362
1815
  receipt: reserved.receipt,
1363
1816
  control: await evaluateSessionControl(db, input.workspaceId, input.sessionId, {
1364
- lock: "share",
1817
+ workspaceControl: workspace,
1365
1818
  }),
1366
1819
  sessionControlEventId,
1367
1820
  workspaceControlEventId,
@@ -1379,7 +1832,7 @@ export async function mutateSessionControlInTransaction(
1379
1832
  });
1380
1833
  }
1381
1834
  const before = await evaluateSessionControl(db, input.workspaceId, input.sessionId, {
1382
- lock: "share",
1835
+ workspaceControl: workspace,
1383
1836
  });
1384
1837
  if (input.expectedControlEtag && input.expectedControlEtag !== before.controlEtag) {
1385
1838
  throw new SessionControlConflictError();
@@ -1541,7 +1994,7 @@ export async function autoResumeSessionBranchInTransaction(
1541
1994
  workspaceId: string;
1542
1995
  sessionId: string;
1543
1996
  actor: string;
1544
- reason: "human_send" | "human_steer" | "agent_steer";
1997
+ reason: "human_send" | "human_steer" | "service_send" | "service_steer" | "agent_steer";
1545
1998
  observedControlEtag?: string | null;
1546
1999
  },
1547
2000
  ): Promise<{
@@ -1748,9 +2201,38 @@ async function insertWorkspaceControlEventInTransaction(
1748
2201
  actor: string;
1749
2202
  },
1750
2203
  ): Promise<string> {
2204
+ const projected = boundWorkspaceControlEvent(
2205
+ {
2206
+ id: crypto.randomUUID(),
2207
+ workspaceId: input.workspaceId,
2208
+ sequence: input.revision,
2209
+ revision: input.revision,
2210
+ type: "workspace.control.changed",
2211
+ scope: input.scope,
2212
+ rootSessionId: input.rootSessionId,
2213
+ action: input.action,
2214
+ automatic: input.automatic,
2215
+ reason: input.reason,
2216
+ actor: input.actor,
2217
+ occurredAt: new Date(0).toISOString(),
2218
+ },
2219
+ { surface: "durable_control" },
2220
+ );
2221
+ const field = (name: "reason" | "actor") =>
2222
+ projected.truncation?.fields.find((candidate) => candidate.field === name);
1751
2223
  const [event] = await db
1752
2224
  .insert(schema.workspaceControlEvents)
1753
- .values(input)
2225
+ .values({
2226
+ ...input,
2227
+ reason: projected.reason,
2228
+ reasonOriginalBytes:
2229
+ input.reason === null
2230
+ ? null
2231
+ : (field("reason")?.originalBytes ?? workspaceControlUtf8Bytes(projected.reason!)),
2232
+ actor: projected.actor,
2233
+ actorOriginalBytes:
2234
+ field("actor")?.originalBytes ?? workspaceControlUtf8Bytes(projected.actor),
2235
+ })
1754
2236
  .returning({ id: schema.workspaceControlEvents.id });
1755
2237
  if (!event) {
1756
2238
  throw new SessionControlInvariantError("Workspace control event was not inserted");