@opengeni/db 0.7.2 → 0.9.3

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