@opengeni/db 3.4.0-canary.2 → 3.5.2-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/canonical-human-identities.d.ts +20 -0
  2. package/dist/canonical-human-identities.js +7 -3
  3. package/dist/{chunk-C2AFP7SB.js → chunk-6NYKC5DD.js} +6507 -6040
  4. package/dist/chunk-6NYKC5DD.js.map +1 -0
  5. package/dist/{chunk-O6FTELKH.js → chunk-FJHEWYV5.js} +3 -3
  6. package/dist/{chunk-ZWRIPCTR.js → chunk-NRGUX3M5.js} +2 -2
  7. package/dist/{chunk-TRBOWUG3.js → chunk-ORKVXHL2.js} +2 -2
  8. package/dist/{chunk-PYMY3AJW.js → chunk-PERPE2UY.js} +172 -7
  9. package/dist/chunk-PERPE2UY.js.map +1 -0
  10. package/dist/chunk-TRCYF26N.js +299 -0
  11. package/dist/chunk-TRCYF26N.js.map +1 -0
  12. package/dist/{chunk-V3TRUXFJ.js → chunk-U7NKCX5E.js} +72 -19
  13. package/dist/chunk-U7NKCX5E.js.map +1 -0
  14. package/dist/{chunk-H7MXYG7R.js → chunk-X6T2P3L3.js} +2 -2
  15. package/dist/{chunk-HY3F5Q4E.js → chunk-Y44FKTYU.js} +3 -3
  16. package/dist/editable-artifact-durable-export.js +2 -2
  17. package/dist/editable-artifacts.js +3 -3
  18. package/dist/index.d.ts +21 -3
  19. package/dist/index.js +1698 -995
  20. package/dist/index.js.map +1 -1
  21. package/dist/managed-auth-session-set-schema.d.ts +1399 -0
  22. package/dist/managed-auth-session-sets.d.ts +145 -0
  23. package/dist/managed-auth-session-sets.js +50 -0
  24. package/dist/managed-auth-session-sets.js.map +1 -0
  25. package/dist/organization-recovery-schema.d.ts +2144 -0
  26. package/dist/organization-recovery.d.ts +186 -0
  27. package/dist/provision-roles.js +1 -1
  28. package/dist/runtime-posture.d.ts +5 -3
  29. package/dist/schema.d.ts +2 -0
  30. package/dist/schema.js +37 -1
  31. package/dist/session-background-commands.js +3 -3
  32. package/dist/session-control.d.ts +5 -3
  33. package/dist/session-queue-commands.d.ts +2 -0
  34. package/dist/session-tenancy.js +3 -3
  35. package/dist/video-generation.js +3 -3
  36. package/drizzle/0362_managed_auth_session_sets.sql +2347 -0
  37. package/drizzle/0363_organization_recovery_custody.sql +2418 -0
  38. package/drizzle/0364_workspace_learning_policy_snapshot_lock_order.sql +221 -0
  39. package/package.json +10 -6
  40. package/src/canonical-human-identities.ts +105 -27
  41. package/src/index.ts +420 -103
  42. package/src/managed-auth-session-set-schema.ts +176 -0
  43. package/src/managed-auth-session-sets.ts +430 -0
  44. package/src/organization-recovery-schema.ts +277 -0
  45. package/src/organization-recovery.ts +424 -0
  46. package/src/provision-roles.ts +49 -0
  47. package/src/runtime-posture.ts +132 -6
  48. package/src/schema.ts +2 -0
  49. package/src/session-control.ts +255 -16
  50. package/src/session-queue-commands.ts +10 -1
  51. package/src/session-tool-call-settlement.ts +6 -1
  52. package/dist/chunk-C2AFP7SB.js.map +0 -1
  53. package/dist/chunk-PYMY3AJW.js.map +0 -1
  54. package/dist/chunk-V3TRUXFJ.js.map +0 -1
  55. /package/dist/{chunk-O6FTELKH.js.map → chunk-FJHEWYV5.js.map} +0 -0
  56. /package/dist/{chunk-ZWRIPCTR.js.map → chunk-NRGUX3M5.js.map} +0 -0
  57. /package/dist/{chunk-TRBOWUG3.js.map → chunk-ORKVXHL2.js.map} +0 -0
  58. /package/dist/{chunk-H7MXYG7R.js.map → chunk-X6T2P3L3.js.map} +0 -0
  59. /package/dist/{chunk-HY3F5Q4E.js.map → chunk-Y44FKTYU.js.map} +0 -0
package/src/index.ts CHANGED
@@ -1508,6 +1508,7 @@ export async function ensureManagedAccessForUser(
1508
1508
  name: string;
1509
1509
  emailVerified?: boolean;
1510
1510
  provisionFallbackOrganization?: boolean;
1511
+ bindPendingInvitations?: boolean;
1511
1512
  },
1512
1513
  ): Promise<AccessContext> {
1513
1514
  return (await ensureManagedAccessForUserWithOrganizationMemberships(db, input)).accessContext;
@@ -1653,6 +1654,7 @@ export async function ensureManagedAccessForUserWithOrganizationMemberships(
1653
1654
  name: string;
1654
1655
  emailVerified?: boolean;
1655
1656
  provisionFallbackOrganization?: boolean;
1657
+ bindPendingInvitations?: boolean;
1656
1658
  },
1657
1659
  ): Promise<ManagedAccessProvisioningResult> {
1658
1660
  const subjectId = `user:${input.userId}`;
@@ -1660,7 +1662,7 @@ export async function ensureManagedAccessForUserWithOrganizationMemberships(
1660
1662
  return await db.transaction(async (tx) => {
1661
1663
  const txDb = tx as unknown as Database;
1662
1664
  await setSubjectRlsContext(txDb, subjectId);
1663
- if (input.emailVerified === true) {
1665
+ if (input.emailVerified === true && input.bindPendingInvitations !== false) {
1664
1666
  await rawRows(
1665
1667
  txDb,
1666
1668
  sql`select bind_pending_organization_invitations_for_verified_email(
@@ -16176,6 +16178,43 @@ export async function listVariableSets(
16176
16178
  });
16177
16179
  }
16178
16180
 
16181
+ export async function resolveVariableSetAttachments(
16182
+ db: Database,
16183
+ context: VariableSetAccessContext,
16184
+ variableSetIds: readonly string[],
16185
+ ): Promise<Array<Pick<VariableSet, "id" | "scope">>> {
16186
+ if (variableSetIds.length === 0) return [];
16187
+ // Drizzle expands a bare JavaScript array into a parameter list rather than
16188
+ // one PostgreSQL array value. Build the bounded requested relation directly.
16189
+ const requestedValues = sql.join(
16190
+ variableSetIds.map(
16191
+ (variableSetId, index) => sql`(${variableSetId}::uuid, ${index + 1}::integer)`,
16192
+ ),
16193
+ sql`, `,
16194
+ );
16195
+ return await withRlsContext(db, context, async (scopedDb) => {
16196
+ await setSubjectRlsContext(scopedDb, context.subjectId);
16197
+ return await rawRows<Pick<VariableSet, "id" | "scope">>(
16198
+ scopedDb,
16199
+ sql`with requested(variable_set_id, ordinal) as (
16200
+ values ${requestedValues}
16201
+ )
16202
+ select
16203
+ resolved.value->>'id' as id,
16204
+ resolved.value->>'scope' as scope
16205
+ from requested
16206
+ cross join lateral list_scoped_variable_sets(
16207
+ ${context.accountId}::uuid,
16208
+ ${context.workspaceId}::uuid,
16209
+ requested.variable_set_id,
16210
+ null,
16211
+ null
16212
+ ) resolved(value)
16213
+ order by requested.ordinal`,
16214
+ );
16215
+ });
16216
+ }
16217
+
16179
16218
  export async function getVariableSet(
16180
16219
  db: Database,
16181
16220
  context: VariableSetAccessContext,
@@ -29002,6 +29041,43 @@ async function lockSessionPersonalStateExclusive(
29002
29041
  );
29003
29042
  }
29004
29043
 
29044
+ async function requireSessionPersonalStateWriteAuthority(
29045
+ db: Database,
29046
+ input: {
29047
+ workspaceId: string;
29048
+ subjectId: string;
29049
+ personalWorkspaceOwnerException?: PersonalWorkspaceOwnerException | undefined;
29050
+ },
29051
+ ): Promise<void> {
29052
+ const [membership] = await db
29053
+ .select({ id: schema.workspaceMemberships.id })
29054
+ .from(schema.workspaceMemberships)
29055
+ .where(
29056
+ and(
29057
+ eq(schema.workspaceMemberships.workspaceId, input.workspaceId),
29058
+ eq(schema.workspaceMemberships.subjectId, input.subjectId),
29059
+ ),
29060
+ )
29061
+ .limit(1);
29062
+ // Managed personal-workspace owners intentionally have no membership row.
29063
+ // The caller may assert this exception only from canonical managed-cookie
29064
+ // authentication; the live organization-membership pointer remains the
29065
+ // database authority and keeps suspended/foreign subjects denied.
29066
+ if (
29067
+ !membership &&
29068
+ !(
29069
+ input.personalWorkspaceOwnerException === true &&
29070
+ (await subjectHasLiveWorkspaceAuthorityInScope(db, {
29071
+ accountId: await accountIdInRlsScope(db),
29072
+ workspaceId: input.workspaceId,
29073
+ subjectId: input.subjectId,
29074
+ }))
29075
+ )
29076
+ ) {
29077
+ throw new SessionPinAccessError();
29078
+ }
29079
+ }
29080
+
29005
29081
  type SessionPinRow = Pick<
29006
29082
  typeof schema.sessionPins.$inferSelect,
29007
29083
  | "pinned"
@@ -30213,36 +30289,7 @@ export async function setSessionPin(
30213
30289
  // serializes with member removal before changing personal state. A stale
30214
30290
  // API grant cannot recreate a pin after removal commits.
30215
30291
  await lockSessionPersonalStateExclusive(tx, input.workspaceId, input.subjectId);
30216
- const [membership] = await tx
30217
- .select({ id: schema.workspaceMemberships.id })
30218
- .from(schema.workspaceMemberships)
30219
- .where(
30220
- and(
30221
- eq(schema.workspaceMemberships.workspaceId, input.workspaceId),
30222
- eq(schema.workspaceMemberships.subjectId, input.subjectId),
30223
- ),
30224
- )
30225
- .limit(1);
30226
- // The owner of a managed personal workspace never has a membership row
30227
- // there (migration 0219 raises on one), so a bare probe would deny every
30228
- // pin inside their own workspace. Defer to the canonical resolver, on
30229
- // this same transaction handle so the exclusive personal-state fence
30230
- // above still serializes it with member removal. Gated on the caller's
30231
- // canonical managed-cookie assertion; see
30232
- // PersonalWorkspaceOwnerException.
30233
- if (
30234
- !membership &&
30235
- !(
30236
- input.personalWorkspaceOwnerException === true &&
30237
- (await subjectHasLiveWorkspaceAuthorityInScope(tx, {
30238
- accountId: await accountIdInRlsScope(tx),
30239
- workspaceId: input.workspaceId,
30240
- subjectId: input.subjectId,
30241
- }))
30242
- )
30243
- ) {
30244
- throw new SessionPinAccessError();
30245
- }
30292
+ await requireSessionPersonalStateWriteAuthority(tx, input);
30246
30293
  const [session] = await tx
30247
30294
  .select()
30248
30295
  .from(schema.sessions)
@@ -30371,6 +30418,8 @@ export async function setSessionAttention(
30371
30418
  acknowledgedThroughSequence?: number | undefined;
30372
30419
  activelyWorking?: boolean | undefined;
30373
30420
  expectedVersion?: number | undefined;
30421
+ /** See {@link PersonalWorkspaceOwnerException}. Absent means "no exception". */
30422
+ personalWorkspaceOwnerException?: PersonalWorkspaceOwnerException | undefined;
30374
30423
  },
30375
30424
  ): Promise<Session | null> {
30376
30425
  return await withWorkspaceSubjectRls(
@@ -30380,17 +30429,7 @@ export async function setSessionAttention(
30380
30429
  async (scopedDb) =>
30381
30430
  await scopedDb.transaction(async (tx) => {
30382
30431
  await lockSessionPersonalStateExclusive(tx, input.workspaceId, input.subjectId);
30383
- const [membership] = await tx
30384
- .select({ id: schema.workspaceMemberships.id })
30385
- .from(schema.workspaceMemberships)
30386
- .where(
30387
- and(
30388
- eq(schema.workspaceMemberships.workspaceId, input.workspaceId),
30389
- eq(schema.workspaceMemberships.subjectId, input.subjectId),
30390
- ),
30391
- )
30392
- .limit(1);
30393
- if (!membership) throw new SessionPinAccessError();
30432
+ await requireSessionPersonalStateWriteAuthority(tx, input);
30394
30433
 
30395
30434
  const [session] = await tx
30396
30435
  .select()
@@ -30542,6 +30581,8 @@ export async function setSessionArchive(
30542
30581
  sessionId: string;
30543
30582
  archived: boolean;
30544
30583
  expectedVersion?: number | undefined;
30584
+ /** See {@link PersonalWorkspaceOwnerException}. Absent means "no exception". */
30585
+ personalWorkspaceOwnerException?: PersonalWorkspaceOwnerException | undefined;
30545
30586
  },
30546
30587
  ): Promise<Session | null> {
30547
30588
  return await withWorkspaceSubjectRls(
@@ -30551,17 +30592,7 @@ export async function setSessionArchive(
30551
30592
  async (scopedDb) =>
30552
30593
  await scopedDb.transaction(async (tx) => {
30553
30594
  await lockSessionPersonalStateExclusive(tx, input.workspaceId, input.subjectId);
30554
- const [membership] = await tx
30555
- .select({ id: schema.workspaceMemberships.id })
30556
- .from(schema.workspaceMemberships)
30557
- .where(
30558
- and(
30559
- eq(schema.workspaceMemberships.workspaceId, input.workspaceId),
30560
- eq(schema.workspaceMemberships.subjectId, input.subjectId),
30561
- ),
30562
- )
30563
- .limit(1);
30564
- if (!membership) throw new SessionPinAccessError();
30595
+ await requireSessionPersonalStateWriteAuthority(tx, input);
30565
30596
 
30566
30597
  const [session] = await tx
30567
30598
  .select()
@@ -32589,6 +32620,45 @@ export async function listSessionHumanInputRequests(
32589
32620
  ): Promise<SessionHumanInputRequest[]> {
32590
32621
  const limit = Math.min(100, Math.max(1, Math.floor(options.limit ?? 50)));
32591
32622
  return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
32623
+ const actionablePendingIds =
32624
+ options.status === "pending"
32625
+ ? await scopedDb.execute<{ id: string }>(sql`
32626
+ select request_row.id
32627
+ from session_human_input_requests request_row
32628
+ join sessions session_row
32629
+ on session_row.workspace_id = request_row.workspace_id
32630
+ and session_row.id = request_row.session_id
32631
+ join session_turns turn_row
32632
+ on turn_row.workspace_id = request_row.workspace_id
32633
+ and turn_row.session_id = request_row.session_id
32634
+ and turn_row.id = request_row.turn_id
32635
+ join session_events trigger_row
32636
+ on trigger_row.workspace_id = turn_row.workspace_id
32637
+ and trigger_row.session_id = turn_row.session_id
32638
+ and trigger_row.id = turn_row.trigger_event_id
32639
+ where request_row.workspace_id = ${workspaceId}
32640
+ and request_row.session_id = ${sessionId}
32641
+ and request_row.status = 'pending'
32642
+ and (request_row.expires_at is null or request_row.expires_at > now())
32643
+ and session_row.status = 'requires_action'
32644
+ and session_row.active_turn_id = request_row.turn_id
32645
+ and turn_row.status = 'requires_action'
32646
+ and turn_row.execution_generation = request_row.turn_generation
32647
+ and not exists (
32648
+ select 1
32649
+ from session_events accepted_row
32650
+ where accepted_row.workspace_id = request_row.workspace_id
32651
+ and accepted_row.session_id = request_row.session_id
32652
+ and accepted_row.turn_id = request_row.turn_id
32653
+ and accepted_row.turn_generation = request_row.turn_generation
32654
+ and accepted_row.type in ('user.approvalDecision', 'user.humanInputResponse')
32655
+ and accepted_row.sequence > trigger_row.sequence
32656
+ )
32657
+ order by request_row.created_at desc, request_row.id desc
32658
+ limit ${limit}
32659
+ `)
32660
+ : null;
32661
+ if (actionablePendingIds !== null && actionablePendingIds.length === 0) return [];
32592
32662
  const predicates = [
32593
32663
  eq(schema.sessionHumanInputRequests.workspaceId, workspaceId),
32594
32664
  eq(schema.sessionHumanInputRequests.sessionId, sessionId),
@@ -32596,6 +32666,14 @@ export async function listSessionHumanInputRequests(
32596
32666
  if (options.status) {
32597
32667
  predicates.push(eq(schema.sessionHumanInputRequests.status, options.status));
32598
32668
  }
32669
+ if (actionablePendingIds !== null) {
32670
+ predicates.push(
32671
+ inArray(
32672
+ schema.sessionHumanInputRequests.id,
32673
+ actionablePendingIds.map((row: { id: string }) => row.id),
32674
+ ),
32675
+ );
32676
+ }
32599
32677
  const rows = await scopedDb
32600
32678
  .select()
32601
32679
  .from(schema.sessionHumanInputRequests)
@@ -32721,6 +32799,13 @@ export type AcceptSessionHumanInputResponseResult =
32721
32799
  events: SessionEvent[];
32722
32800
  workflowWakeRevision: number;
32723
32801
  }
32802
+ | {
32803
+ action: "completed";
32804
+ request: SessionHumanInputRequest;
32805
+ event: SessionEvent;
32806
+ events: SessionEvent[];
32807
+ workflowWakeRevision: number | null;
32808
+ }
32724
32809
  | {
32725
32810
  action: "conflict";
32726
32811
  request: SessionHumanInputRequest;
@@ -32774,10 +32859,37 @@ async function hasAcceptedRequiresActionAdvance(
32774
32859
  return accepted !== undefined;
32775
32860
  }
32776
32861
 
32862
+ async function humanInputResponseEventForRequest(
32863
+ tx: Database,
32864
+ input: {
32865
+ workspaceId: string;
32866
+ sessionId: string;
32867
+ requestId: string;
32868
+ },
32869
+ ): Promise<SessionEvent | null> {
32870
+ const [event] = await tx
32871
+ .select()
32872
+ .from(schema.sessionEvents)
32873
+ .where(
32874
+ and(
32875
+ eq(schema.sessionEvents.workspaceId, input.workspaceId),
32876
+ eq(schema.sessionEvents.sessionId, input.sessionId),
32877
+ eq(schema.sessionEvents.type, "user.humanInputResponse"),
32878
+ sql`${schema.sessionEvents.payload} ->> 'requestId' = ${input.requestId}`,
32879
+ ),
32880
+ )
32881
+ .orderBy(desc(schema.sessionEvents.sequence))
32882
+ .limit(1);
32883
+ return event ? mapEvent(event) : null;
32884
+ }
32885
+
32777
32886
  /**
32778
- * First-writer-wins settlement for one pending structured input boundary.
32779
- * Session + logical turn + current freeze generation are locked with the row;
32780
- * the creation attempt is provenance and is intentionally not the resume owner.
32887
+ * First-writer-wins settlement for one structured input boundary. Session +
32888
+ * logical turn + current freeze generation are locked with the row; the
32889
+ * creation attempt is provenance and is intentionally not the resume owner.
32890
+ * A committed response replays its original event without another event or
32891
+ * workflow wake. Historical cancelled rows missing that canonical event are
32892
+ * repaired exactly once under the same locks, but never wake terminal work.
32781
32893
  */
32782
32894
  export async function acceptSessionHumanInputResponse(
32783
32895
  db: Database,
@@ -32845,25 +32957,105 @@ export async function acceptSessionHumanInputResponse(
32845
32957
  ) {
32846
32958
  throw new Error("clientEventId belongs to a different session operation");
32847
32959
  }
32848
- const workflowWakeRevision = await enqueueSessionWorkflowWakeInTransaction(
32849
- tx as unknown as Database,
32850
- {
32851
- accountId: session.accountId,
32852
- workspaceId: input.workspaceId,
32853
- sessionId: session.id,
32854
- temporalWorkflowId: session.temporalWorkflowId ?? `session-${session.id}`,
32855
- reason: "approval_decision",
32856
- },
32857
- );
32858
32960
  return {
32859
- action: "accepted",
32961
+ action: "completed",
32860
32962
  request: mapSessionHumanInputRequest(request),
32861
32963
  event: mapEvent(existing),
32862
32964
  events: [],
32863
- workflowWakeRevision,
32965
+ workflowWakeRevision: null,
32864
32966
  } as const;
32865
32967
  }
32866
32968
  }
32969
+ if (request.status !== "pending") {
32970
+ let event = await humanInputResponseEventForRequest(tx as unknown as Database, {
32971
+ workspaceId: input.workspaceId,
32972
+ sessionId: input.sessionId,
32973
+ requestId: request.id,
32974
+ });
32975
+ let repairedEvents: SessionEvent[] = [];
32976
+ if (
32977
+ !event &&
32978
+ request.status === "cancelled" &&
32979
+ (request.response as { outcome?: unknown } | null)?.outcome === "cancelled"
32980
+ ) {
32981
+ const turnLocks = await lockSessionEventWriteRows(tx as unknown as Database, {
32982
+ workspaceId: input.workspaceId,
32983
+ controlLock: "already_locked",
32984
+ workspaceLock: "already_locked",
32985
+ turnIds: [request.turnId],
32986
+ });
32987
+ const turn = turnLocks.turns[0];
32988
+ if (!turn || turn.sessionId !== session.id) {
32989
+ throw new SessionControlInvariantError(
32990
+ `Cancelled human-input request ${request.id} has no owning turn`,
32991
+ );
32992
+ }
32993
+ const occurredAt = request.respondedAt ?? request.updatedAt;
32994
+ const [inserted] = await tx
32995
+ .insert(schema.sessionEvents)
32996
+ .values(
32997
+ withLosslessContentWriteVersion(
32998
+ {
32999
+ accountId: session.accountId,
33000
+ workspaceId: input.workspaceId,
33001
+ sessionId: session.id,
33002
+ turnId: request.turnId,
33003
+ turnGeneration: request.turnGeneration,
33004
+ turnAssociation: "current",
33005
+ sequence: session.lastSequence + 1,
33006
+ type: "user.humanInputResponse",
33007
+ payload: { requestId: request.id, response: { outcome: "cancelled" } },
33008
+ clientEventId: null,
33009
+ occurredAt,
33010
+ },
33011
+ "payload",
33012
+ "payloadCodecVersion",
33013
+ ),
33014
+ )
33015
+ .returning();
33016
+ if (!inserted) {
33017
+ throw new SessionControlInvariantError(
33018
+ `Cancelled human-input request ${request.id} could not repair its response event`,
33019
+ );
33020
+ }
33021
+ await mirrorSessionRealtimeContextInTransaction(tx as unknown as Database, {
33022
+ accountId: session.accountId,
33023
+ workspaceId: input.workspaceId,
33024
+ sessionId: session.id,
33025
+ sourceKind: "human_input_response",
33026
+ sourceId: inserted.id,
33027
+ turnId: request.turnId,
33028
+ channel: null,
33029
+ text: renderRealtimeHumanInputResponseContext({
33030
+ requestId: request.id,
33031
+ questions: request.questions,
33032
+ response: { outcome: "cancelled" },
33033
+ }),
33034
+ payload: {
33035
+ requestId: request.id,
33036
+ outcome: "cancelled",
33037
+ sourceEventId: inserted.id,
33038
+ },
33039
+ now: occurredAt,
33040
+ });
33041
+ await tx
33042
+ .update(schema.sessions)
33043
+ .set({ lastSequence: session.lastSequence + 1, updatedAt: new Date() })
33044
+ .where(eq(schema.sessions.id, session.id));
33045
+ event = mapEvent(inserted);
33046
+ repairedEvents = [event];
33047
+ }
33048
+ if (!event) {
33049
+ throw new Error(`Terminal human-input request has no response event: ${request.id}`);
33050
+ }
33051
+ return {
33052
+ action: "completed",
33053
+ request: mapSessionHumanInputRequest(request),
33054
+ event,
33055
+ events: repairedEvents,
33056
+ workflowWakeRevision: null,
33057
+ } as const;
33058
+ }
32867
33059
  const [turnPreview] = session.activeTurnId
32868
33060
  ? await tx
32869
33061
  .select()
@@ -32892,7 +33084,7 @@ export async function acceptSessionHumanInputResponse(
32892
33084
  turn?.status === "requires_action" &&
32893
33085
  turn.id === request.turnId &&
32894
33086
  turn.executionGeneration === request.turnGeneration;
32895
- if (request.status !== "pending" || !boundaryOwnsRequest) {
33087
+ if (!boundaryOwnsRequest) {
32896
33088
  return {
32897
33089
  action: "conflict",
32898
33090
  request: mapSessionHumanInputRequest(request),
@@ -33033,9 +33225,7 @@ export async function acceptSessionHumanInputResponse(
33033
33225
  events: [mappedEvent],
33034
33226
  workflowWakeRevision,
33035
33227
  };
33036
- return expired
33037
- ? ({ action: "conflict", ...result } as const)
33038
- : ({ action: "accepted", event: mappedEvent, ...result } as const);
33228
+ return { action: "accepted", event: mappedEvent, ...result } as const;
33039
33229
  }),
33040
33230
  );
33041
33231
  }
@@ -42096,6 +42286,65 @@ async function lockWorkspaceMutationSessionTx(
42096
42286
  return session;
42097
42287
  }
42098
42288
 
42289
+ /** True while an exact closed attempt still owns the physical-quiescence gate.
42290
+ * Both user control interruptions and activity-owned recoverable shutdowns use
42291
+ * this predicate so wake delivery cannot turn an unfinished writer drain into
42292
+ * an acknowledged edge-trigger. */
42293
+ async function hasPendingSessionAttemptQuiescenceTx(
42294
+ tx: Database,
42295
+ input: { workspaceId: string; sessionId: string; attemptId?: string },
42296
+ ): Promise<boolean> {
42297
+ const [row] = await tx.execute<{ pending: boolean }>(sql`
42298
+ select exists (
42299
+ select 1
42300
+ from session_turn_attempts attempt
42301
+ where attempt.workspace_id = ${input.workspaceId}
42302
+ and attempt.session_id = ${input.sessionId}
42303
+ ${input.attemptId ? sql`and attempt.id = ${input.attemptId}` : sql``}
42304
+ and attempt.state = 'closed'
42305
+ and attempt.quiesced_at is null
42306
+ and (
42307
+ exists (
42308
+ select 1
42309
+ from session_attempt_interruptions interruption
42310
+ where interruption.workspace_id = attempt.workspace_id
42311
+ and interruption.session_id = attempt.session_id
42312
+ and interruption.attempt_id = attempt.id
42313
+ and interruption.state in ('settled', 'rejected_stale')
42314
+ )
42315
+ or (
42316
+ attempt.outcome = 'interrupted_recoverable'
42317
+ and not exists (
42318
+ select 1
42319
+ from session_turn_attempts successor
42320
+ where successor.account_id = attempt.account_id
42321
+ and successor.workspace_id = attempt.workspace_id
42322
+ and successor.session_id = attempt.session_id
42323
+ and (
42324
+ successor.started_at > attempt.started_at
42325
+ or (
42326
+ successor.started_at = attempt.started_at
42327
+ and successor.id > attempt.id
42328
+ )
42329
+ )
42330
+ )
42331
+ and exists (
42332
+ select 1
42333
+ from session_events event
42334
+ where event.account_id = attempt.account_id
42335
+ and event.workspace_id = attempt.workspace_id
42336
+ and event.session_id = attempt.session_id
42337
+ and event.turn_id = attempt.turn_id
42338
+ and event.turn_attempt_id = attempt.id
42339
+ and event.type = 'turn.recovery.requested'
42340
+ )
42341
+ )
42342
+ )
42343
+ ) as pending
42344
+ `);
42345
+ return row?.pending === true;
42346
+ }
42347
+
42099
42348
  /**
42100
42349
  * Resolve the organization grant identity behind a causal human, and refuse the
42101
42350
  * operation when that grant has been revoked or suspended.
@@ -44073,7 +44322,11 @@ export async function settleRetainedProcess(
44073
44322
  async (scopedDb) =>
44074
44323
  await scopedDb.transaction(async (txRaw) => {
44075
44324
  const tx = txRaw as unknown as Database;
44076
- await lockWorkspaceMutationSessionTx(tx, input.workspaceId, input.sessionId);
44325
+ const session = await lockWorkspaceMutationSessionTx(
44326
+ tx,
44327
+ input.workspaceId,
44328
+ input.sessionId,
44329
+ );
44077
44330
  const [process] = await tx
44078
44331
  .select()
44079
44332
  .from(schema.sandboxRetainedProcesses)
@@ -44280,6 +44533,22 @@ export async function settleRetainedProcess(
44280
44533
  where id = ${process.leaseId}
44281
44534
  and sandbox_group_id = ${process.sandboxGroupId}
44282
44535
  `);
44536
+ if (
44537
+ process.ownerAttemptId &&
44538
+ (await hasPendingSessionAttemptQuiescenceTx(tx, {
44539
+ workspaceId: input.workspaceId,
44540
+ sessionId: input.sessionId,
44541
+ attemptId: process.ownerAttemptId,
44542
+ }))
44543
+ ) {
44544
+ await enqueueSessionWorkflowWakeInTransaction(tx, {
44545
+ accountId: input.accountId,
44546
+ workspaceId: input.workspaceId,
44547
+ sessionId: input.sessionId,
44548
+ temporalWorkflowId: session.temporalWorkflowId ?? `session-${input.sessionId}`,
44549
+ reason: "retained_process_settled_quiescence",
44550
+ });
44551
+ }
44283
44552
  return { settled: true, process: mapRetainedProcess(updated) };
44284
44553
  }),
44285
44554
  );
@@ -59847,7 +60116,12 @@ export async function failSessionWorkBeforeAttemptClaim(
59847
60116
  workspaceId,
59848
60117
  {
59849
60118
  stage: "session_lifecycle_outbox.fail_before_attempt_claim",
59850
- eventTypes: ["child_terminal_result", "session.status.changed", "turn.failed"],
60119
+ eventTypes: [
60120
+ "child_terminal_result",
60121
+ "session.status.changed",
60122
+ "turn.failed",
60123
+ "user.humanInputResponse",
60124
+ ],
59851
60125
  maxAttempts: 3,
59852
60126
  },
59853
60127
  async (scopedDb) =>
@@ -60041,6 +60315,11 @@ export async function failSessionWorkBeforeAttemptClaim(
60041
60315
  const now = new Date();
60042
60316
  let sequence = session.lastSequence;
60043
60317
  let closedToolEvents: SessionEvent[] = [];
60318
+ let cancelledHumanInputs: Array<{
60319
+ id: string;
60320
+ questions: HumanInputQuestion[];
60321
+ turnGeneration: number;
60322
+ }> = [];
60044
60323
  if (turn) {
60045
60324
  await cancelTurnInteractionInterventionsInTransaction(tx as unknown as Database, {
60046
60325
  accountId: session.accountId,
@@ -60062,7 +60341,7 @@ export async function failSessionWorkBeforeAttemptClaim(
60062
60341
  );
60063
60342
  sequence = closedTools.sequence;
60064
60343
  closedToolEvents = closedTools.events;
60065
- const cancelledHumanInputs = await tx
60344
+ cancelledHumanInputs = await tx
60066
60345
  .update(schema.sessionHumanInputRequests)
60067
60346
  .set({
60068
60347
  status: "cancelled",
@@ -60081,6 +60360,7 @@ export async function failSessionWorkBeforeAttemptClaim(
60081
60360
  )
60082
60361
  .returning({
60083
60362
  id: schema.sessionHumanInputRequests.id,
60363
+ questions: schema.sessionHumanInputRequests.questions,
60084
60364
  turnGeneration: schema.sessionHumanInputRequests.turnGeneration,
60085
60365
  });
60086
60366
  // The child's blocked boundary died with the turn: resolve each
@@ -60120,6 +60400,21 @@ export async function failSessionWorkBeforeAttemptClaim(
60120
60400
  );
60121
60401
  }
60122
60402
  const values = [
60403
+ ...(turn
60404
+ ? cancelledHumanInputs.map((request) => ({
60405
+ accountId: session.accountId,
60406
+ workspaceId,
60407
+ sessionId: input.sessionId,
60408
+ sequence: ++sequence,
60409
+ type: "user.humanInputResponse" as const,
60410
+ payload: { requestId: request.id, response: { outcome: "cancelled" as const } },
60411
+ turnId: turn.id,
60412
+ turnGeneration: request.turnGeneration,
60413
+ turnAttemptId: null,
60414
+ turnAssociation: "current" as const,
60415
+ occurredAt: now,
60416
+ }))
60417
+ : []),
60123
60418
  ...(turn
60124
60419
  ? [
60125
60420
  {
@@ -60159,6 +60454,36 @@ export async function failSessionWorkBeforeAttemptClaim(
60159
60454
  .insert(schema.sessionEvents)
60160
60455
  .values(withLosslessContentWriteVersion(values, "payload", "payloadCodecVersion"))
60161
60456
  .returning();
60457
+ const cancelledHumanInputById = new Map(
60458
+ cancelledHumanInputs.map((request) => [request.id, request]),
60459
+ );
60460
+ for (const event of inserted) {
60461
+ if (event.type !== "user.humanInputResponse") continue;
60462
+ const payload = sessionEventPayloadRecord(event.payload, event.payloadCodecVersion);
60463
+ const requestId = typeof payload.requestId === "string" ? payload.requestId : null;
60464
+ const request = requestId ? cancelledHumanInputById.get(requestId) : null;
60465
+ if (!request || !turn) continue;
60466
+ await mirrorSessionRealtimeContextInTransaction(tx as unknown as Database, {
60467
+ accountId: session.accountId,
60468
+ workspaceId,
60469
+ sessionId: input.sessionId,
60470
+ sourceKind: "human_input_response",
60471
+ sourceId: event.id,
60472
+ turnId: turn.id,
60473
+ channel: null,
60474
+ text: renderRealtimeHumanInputResponseContext({
60475
+ requestId: request.id,
60476
+ questions: request.questions,
60477
+ response: { outcome: "cancelled" },
60478
+ }),
60479
+ payload: {
60480
+ requestId: request.id,
60481
+ outcome: "cancelled",
60482
+ sourceEventId: event.id,
60483
+ },
60484
+ now,
60485
+ });
60486
+ }
60162
60487
  if (turn) {
60163
60488
  const failedEvent = inserted.find((event) => event.type === "turn.failed");
60164
60489
  if (!failedEvent) {
@@ -62620,6 +62945,13 @@ export async function requestSessionTurnRecovery(
62620
62945
  if (!updatedSession) {
62621
62946
  throw new Error(`Active session turn changed while locked: ${input.turnId}`);
62622
62947
  }
62948
+ await enqueueSessionWorkflowWakeInTransaction(tx as unknown as Database, {
62949
+ accountId: session.accountId,
62950
+ workspaceId,
62951
+ sessionId: input.sessionId,
62952
+ temporalWorkflowId: session.temporalWorkflowId ?? `session-${input.sessionId}`,
62953
+ reason: "turn_recovery_requested",
62954
+ });
62623
62955
  return {
62624
62956
  action: "recovering" as const,
62625
62957
  events: [...closedTools.events, ...inserted.map(mapEvent)],
@@ -63239,7 +63571,11 @@ export async function getScheduledTargetSessionExecution(
63239
63571
  { latencyMode: latestStarted?.latencyMode },
63240
63572
  session.latencyMode as LatencyMode,
63241
63573
  ),
63242
- tools: (latestStarted?.tools ?? session.tools) as ToolRef[],
63574
+ // A turn stores omitted `tools` as the non-null database default `[]`.
63575
+ // Only the companion marker distinguishes that inherited state from an
63576
+ // explicit empty override, so never let the array alone narrow a later
63577
+ // scheduled occurrence.
63578
+ tools: (latestStarted?.toolsProvided ? latestStarted.tools : session.tools) as ToolRef[],
63243
63579
  sandboxBackend: (latestStarted?.sandboxBackend ?? session.sandboxBackend) as SandboxBackend,
63244
63580
  sandboxOs: (latestStarted?.sandboxOs ?? session.sandboxOs) as SandboxOs,
63245
63581
  firstPartyMcpTools: session.firstPartyMcpTools as FirstPartyMcpToolName[],
@@ -64360,33 +64696,12 @@ export async function markSessionWorkflowWakeDelivered(
64360
64696
  blocker: "pending_agent_steer",
64361
64697
  } as const;
64362
64698
  }
64363
- const [pendingQuiescence] = await tx
64364
- .select({ id: schema.sessionTurnAttempts.id })
64365
- .from(schema.sessionTurnAttempts)
64366
- .innerJoin(
64367
- schema.sessionAttemptInterruptions,
64368
- and(
64369
- eq(
64370
- schema.sessionAttemptInterruptions.workspaceId,
64371
- schema.sessionTurnAttempts.workspaceId,
64372
- ),
64373
- eq(
64374
- schema.sessionAttemptInterruptions.sessionId,
64375
- schema.sessionTurnAttempts.sessionId,
64376
- ),
64377
- eq(schema.sessionAttemptInterruptions.attemptId, schema.sessionTurnAttempts.id),
64378
- ),
64379
- )
64380
- .where(
64381
- and(
64382
- eq(schema.sessionTurnAttempts.workspaceId, input.workspaceId),
64383
- eq(schema.sessionTurnAttempts.sessionId, input.sessionId),
64384
- isNull(schema.sessionTurnAttempts.quiescedAt),
64385
- inArray(schema.sessionAttemptInterruptions.state, ["settled", "rejected_stale"]),
64386
- ),
64387
- )
64388
- .limit(1);
64389
- if (pendingQuiescence) {
64699
+ if (
64700
+ await hasPendingSessionAttemptQuiescenceTx(tx as unknown as Database, {
64701
+ workspaceId: input.workspaceId,
64702
+ sessionId: input.sessionId,
64703
+ })
64704
+ ) {
64390
64705
  return {
64391
64706
  action: "pending_admission",
64392
64707
  blocker: "pending_quiescence",
@@ -67885,6 +68200,8 @@ export * from "./attached-browser-devices";
67885
68200
  export * from "./interaction-revisions";
67886
68201
  export * from "./interaction-placement-loss";
67887
68202
  export * from "./canonical-human-identities";
68203
+ export * from "./managed-auth-session-sets";
68204
+ export * from "./organization-recovery";
67888
68205
  export * from "./session-tenancy";
67889
68206
  export * from "./governed-learning-activation";
67890
68207
  export * from "./automations";