@opengeni/db 3.4.0-canary.2 → 3.6.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 (67) 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-ZWRIPCTR.js → chunk-2NT476HD.js} +2 -2
  4. package/dist/{chunk-PYMY3AJW.js → chunk-744DFC65.js} +202 -7
  5. package/dist/chunk-744DFC65.js.map +1 -0
  6. package/dist/{chunk-O6FTELKH.js → chunk-CDMMSUWJ.js} +3 -3
  7. package/dist/chunk-G3732M3T.js +299 -0
  8. package/dist/chunk-G3732M3T.js.map +1 -0
  9. package/dist/{chunk-TRBOWUG3.js → chunk-IW6O6G7R.js} +2 -2
  10. package/dist/{chunk-HY3F5Q4E.js → chunk-Q5YUGQVK.js} +3 -3
  11. package/dist/{chunk-V3TRUXFJ.js → chunk-UGDVSQ6U.js} +72 -19
  12. package/dist/chunk-UGDVSQ6U.js.map +1 -0
  13. package/dist/{chunk-C2AFP7SB.js → chunk-UJ2EW3QI.js} +6664 -6051
  14. package/dist/chunk-UJ2EW3QI.js.map +1 -0
  15. package/dist/{chunk-H7MXYG7R.js → chunk-WW7AJ5IC.js} +2 -2
  16. package/dist/editable-artifact-durable-export.js +2 -2
  17. package/dist/editable-artifacts.js +3 -3
  18. package/dist/index.d.ts +44 -6
  19. package/dist/index.js +2914 -1535
  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 +7 -4
  29. package/dist/schema.d.ts +9 -6
  30. package/dist/schema.js +43 -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/dist/work-claims-schema.d.ts +975 -0
  37. package/dist/work-claims.d.ts +83 -0
  38. package/drizzle/0362_managed_auth_session_sets.sql +2347 -0
  39. package/drizzle/0363_organization_recovery_custody.sql +2418 -0
  40. package/drizzle/0364_workspace_learning_policy_snapshot_lock_order.sql +221 -0
  41. package/drizzle/0365_permission_scoped_work_claims.sql +1306 -0
  42. package/drizzle/0366_session_discovery_title_search_index.sql +5 -0
  43. package/drizzle/0367_session_discovery_goal_search_index.sql +5 -0
  44. package/drizzle/0368_session_discovery_claim_search_index.sql +6 -0
  45. package/package.json +10 -6
  46. package/src/canonical-human-identities.ts +105 -27
  47. package/src/index.ts +1471 -433
  48. package/src/managed-auth-session-set-schema.ts +176 -0
  49. package/src/managed-auth-session-sets.ts +430 -0
  50. package/src/organization-recovery-schema.ts +277 -0
  51. package/src/organization-recovery.ts +424 -0
  52. package/src/provision-roles.ts +67 -0
  53. package/src/runtime-posture.ts +144 -6
  54. package/src/schema.ts +3 -0
  55. package/src/session-control.ts +255 -16
  56. package/src/session-queue-commands.ts +10 -1
  57. package/src/session-tool-call-settlement.ts +6 -1
  58. package/src/work-claims-schema.ts +148 -0
  59. package/src/work-claims.ts +200 -0
  60. package/dist/chunk-C2AFP7SB.js.map +0 -1
  61. package/dist/chunk-PYMY3AJW.js.map +0 -1
  62. package/dist/chunk-V3TRUXFJ.js.map +0 -1
  63. /package/dist/{chunk-ZWRIPCTR.js.map → chunk-2NT476HD.js.map} +0 -0
  64. /package/dist/{chunk-O6FTELKH.js.map → chunk-CDMMSUWJ.js.map} +0 -0
  65. /package/dist/{chunk-TRBOWUG3.js.map → chunk-IW6O6G7R.js.map} +0 -0
  66. /package/dist/{chunk-HY3F5Q4E.js.map → chunk-Q5YUGQVK.js.map} +0 -0
  67. /package/dist/{chunk-H7MXYG7R.js.map → chunk-WW7AJ5IC.js.map} +0 -0
package/src/index.ts CHANGED
@@ -20,9 +20,21 @@ import {
20
20
  SessionGoalSnapshot,
21
21
  AUTOMATIC_SESSION_TITLE_FALLBACK,
22
22
  ScheduledTaskRunAcceptedExecution,
23
+ WORK_CLAIM_DISCOVERY_DEFAULT_LIMIT,
24
+ WORK_CLAIM_DISCOVERY_LIMIT,
25
+ WORK_DISCOVERY_QUERY_MAX_CHARS,
26
+ WORK_DISCOVERY_RECENT_HOURS_MAX,
27
+ WorkClaimDiscoverySummary as WorkClaimDiscoverySummarySchema,
28
+ WorkDiscoveryMatch as WorkDiscoveryMatchSchema,
29
+ WorkClaimNamespace,
30
+ WorkClaimSubjectType,
31
+ WorkClaimCanonicalKey,
32
+ SessionStatus as SessionStatusSchema,
23
33
  SessionGoalRootConstraintsWrite,
24
34
  normalizeSessionGoalRootConstraints,
25
35
  normalizeAutomaticSessionTitle,
36
+ normalizeWorkClaimCanonicalKey,
37
+ normalizeWorkClaimNamespace,
26
38
  sessionVisibilityToPublic,
27
39
  sessionGoalUtf8Bytes,
28
40
  } from "@opengeni/contracts";
@@ -154,6 +166,9 @@ import type {
154
166
  WorkspaceArchiveDescriptor,
155
167
  ModalCheckpointProviderBinding,
156
168
  SandboxProviderContinuityRecovery,
169
+ WorkClaimDiscoverySummary,
170
+ WorkClaimSubjectFilter,
171
+ WorkDiscoveryProjection,
157
172
  } from "@opengeni/contracts";
158
173
  import {
159
174
  closePrivateSessionCreateCapability,
@@ -273,6 +288,7 @@ import {
273
288
  sql,
274
289
  type SQL,
275
290
  } from "drizzle-orm";
291
+ import type { PgTransactionConfig } from "drizzle-orm/pg-core";
276
292
  import {
277
293
  creatorColumns,
278
294
  frozenInitiatorForCommandActor,
@@ -426,6 +442,7 @@ export * from "./company-brain-governed-writes";
426
442
  export * from "./company-brain-context-selection";
427
443
  export * from "./knowledge-source-sync";
428
444
  export * from "./task-notes";
445
+ export * from "./work-claims";
429
446
  export * from "./managed-human-provisioning";
430
447
  export * from "./managed-user-setup";
431
448
  export * from "./organization-membership-backfill";
@@ -1508,6 +1525,7 @@ export async function ensureManagedAccessForUser(
1508
1525
  name: string;
1509
1526
  emailVerified?: boolean;
1510
1527
  provisionFallbackOrganization?: boolean;
1528
+ bindPendingInvitations?: boolean;
1511
1529
  },
1512
1530
  ): Promise<AccessContext> {
1513
1531
  return (await ensureManagedAccessForUserWithOrganizationMemberships(db, input)).accessContext;
@@ -1653,6 +1671,7 @@ export async function ensureManagedAccessForUserWithOrganizationMemberships(
1653
1671
  name: string;
1654
1672
  emailVerified?: boolean;
1655
1673
  provisionFallbackOrganization?: boolean;
1674
+ bindPendingInvitations?: boolean;
1656
1675
  },
1657
1676
  ): Promise<ManagedAccessProvisioningResult> {
1658
1677
  const subjectId = `user:${input.userId}`;
@@ -1660,7 +1679,7 @@ export async function ensureManagedAccessForUserWithOrganizationMemberships(
1660
1679
  return await db.transaction(async (tx) => {
1661
1680
  const txDb = tx as unknown as Database;
1662
1681
  await setSubjectRlsContext(txDb, subjectId);
1663
- if (input.emailVerified === true) {
1682
+ if (input.emailVerified === true && input.bindPendingInvitations !== false) {
1664
1683
  await rawRows(
1665
1684
  txDb,
1666
1685
  sql`select bind_pending_organization_invitations_for_verified_email(
@@ -16176,6 +16195,43 @@ export async function listVariableSets(
16176
16195
  });
16177
16196
  }
16178
16197
 
16198
+ export async function resolveVariableSetAttachments(
16199
+ db: Database,
16200
+ context: VariableSetAccessContext,
16201
+ variableSetIds: readonly string[],
16202
+ ): Promise<Array<Pick<VariableSet, "id" | "scope">>> {
16203
+ if (variableSetIds.length === 0) return [];
16204
+ // Drizzle expands a bare JavaScript array into a parameter list rather than
16205
+ // one PostgreSQL array value. Build the bounded requested relation directly.
16206
+ const requestedValues = sql.join(
16207
+ variableSetIds.map(
16208
+ (variableSetId, index) => sql`(${variableSetId}::uuid, ${index + 1}::integer)`,
16209
+ ),
16210
+ sql`, `,
16211
+ );
16212
+ return await withRlsContext(db, context, async (scopedDb) => {
16213
+ await setSubjectRlsContext(scopedDb, context.subjectId);
16214
+ return await rawRows<Pick<VariableSet, "id" | "scope">>(
16215
+ scopedDb,
16216
+ sql`with requested(variable_set_id, ordinal) as (
16217
+ values ${requestedValues}
16218
+ )
16219
+ select
16220
+ resolved.value->>'id' as id,
16221
+ resolved.value->>'scope' as scope
16222
+ from requested
16223
+ cross join lateral list_scoped_variable_sets(
16224
+ ${context.accountId}::uuid,
16225
+ ${context.workspaceId}::uuid,
16226
+ requested.variable_set_id,
16227
+ null,
16228
+ null
16229
+ ) resolved(value)
16230
+ order by requested.ordinal`,
16231
+ );
16232
+ });
16233
+ }
16234
+
16179
16235
  export async function getVariableSet(
16180
16236
  db: Database,
16181
16237
  context: VariableSetAccessContext,
@@ -29002,6 +29058,43 @@ async function lockSessionPersonalStateExclusive(
29002
29058
  );
29003
29059
  }
29004
29060
 
29061
+ async function requireSessionPersonalStateWriteAuthority(
29062
+ db: Database,
29063
+ input: {
29064
+ workspaceId: string;
29065
+ subjectId: string;
29066
+ personalWorkspaceOwnerException?: PersonalWorkspaceOwnerException | undefined;
29067
+ },
29068
+ ): Promise<void> {
29069
+ const [membership] = await db
29070
+ .select({ id: schema.workspaceMemberships.id })
29071
+ .from(schema.workspaceMemberships)
29072
+ .where(
29073
+ and(
29074
+ eq(schema.workspaceMemberships.workspaceId, input.workspaceId),
29075
+ eq(schema.workspaceMemberships.subjectId, input.subjectId),
29076
+ ),
29077
+ )
29078
+ .limit(1);
29079
+ // Managed personal-workspace owners intentionally have no membership row.
29080
+ // The caller may assert this exception only from canonical managed-cookie
29081
+ // authentication; the live organization-membership pointer remains the
29082
+ // database authority and keeps suspended/foreign subjects denied.
29083
+ if (
29084
+ !membership &&
29085
+ !(
29086
+ input.personalWorkspaceOwnerException === true &&
29087
+ (await subjectHasLiveWorkspaceAuthorityInScope(db, {
29088
+ accountId: await accountIdInRlsScope(db),
29089
+ workspaceId: input.workspaceId,
29090
+ subjectId: input.subjectId,
29091
+ }))
29092
+ )
29093
+ ) {
29094
+ throw new SessionPinAccessError();
29095
+ }
29096
+ }
29097
+
29005
29098
  type SessionPinRow = Pick<
29006
29099
  typeof schema.sessionPins.$inferSelect,
29007
29100
  | "pinned"
@@ -30213,36 +30306,7 @@ export async function setSessionPin(
30213
30306
  // serializes with member removal before changing personal state. A stale
30214
30307
  // API grant cannot recreate a pin after removal commits.
30215
30308
  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
- }
30309
+ await requireSessionPersonalStateWriteAuthority(tx, input);
30246
30310
  const [session] = await tx
30247
30311
  .select()
30248
30312
  .from(schema.sessions)
@@ -30371,6 +30435,8 @@ export async function setSessionAttention(
30371
30435
  acknowledgedThroughSequence?: number | undefined;
30372
30436
  activelyWorking?: boolean | undefined;
30373
30437
  expectedVersion?: number | undefined;
30438
+ /** See {@link PersonalWorkspaceOwnerException}. Absent means "no exception". */
30439
+ personalWorkspaceOwnerException?: PersonalWorkspaceOwnerException | undefined;
30374
30440
  },
30375
30441
  ): Promise<Session | null> {
30376
30442
  return await withWorkspaceSubjectRls(
@@ -30380,17 +30446,7 @@ export async function setSessionAttention(
30380
30446
  async (scopedDb) =>
30381
30447
  await scopedDb.transaction(async (tx) => {
30382
30448
  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();
30449
+ await requireSessionPersonalStateWriteAuthority(tx, input);
30394
30450
 
30395
30451
  const [session] = await tx
30396
30452
  .select()
@@ -30542,6 +30598,8 @@ export async function setSessionArchive(
30542
30598
  sessionId: string;
30543
30599
  archived: boolean;
30544
30600
  expectedVersion?: number | undefined;
30601
+ /** See {@link PersonalWorkspaceOwnerException}. Absent means "no exception". */
30602
+ personalWorkspaceOwnerException?: PersonalWorkspaceOwnerException | undefined;
30545
30603
  },
30546
30604
  ): Promise<Session | null> {
30547
30605
  return await withWorkspaceSubjectRls(
@@ -30551,17 +30609,7 @@ export async function setSessionArchive(
30551
30609
  async (scopedDb) =>
30552
30610
  await scopedDb.transaction(async (tx) => {
30553
30611
  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();
30612
+ await requireSessionPersonalStateWriteAuthority(tx, input);
30565
30613
 
30566
30614
  const [session] = await tx
30567
30615
  .select()
@@ -30792,9 +30840,11 @@ export async function getLatestSessionModelForSubject(
30792
30840
  });
30793
30841
  }
30794
30842
 
30795
- export type SessionDiscoveryOrderBy = "createdAt" | "updatedAt";
30843
+ export type SessionDiscoveryOrderBy = "createdAt" | "updatedAt" | "relevance";
30796
30844
  export type SessionDiscoveryCursor = {
30797
30845
  orderBy: SessionDiscoveryOrderBy;
30846
+ /** Stable rank class for relevance order; null for chronological order. */
30847
+ sortRank: number | null;
30798
30848
  /** Decimal database activity revision; zero is the legacy/created-order bucket. */
30799
30849
  sortRevision: string;
30800
30850
  /** Exact PostgreSQL timestamp text (including microseconds), not a JS Date. */
@@ -30806,6 +30856,8 @@ export type SessionDiscoveryCursor = {
30806
30856
  snapshotRevision: string;
30807
30857
  /** Incremental revision scope is cursor-bound and cannot change on continuation. */
30808
30858
  updatedAfter: string | null;
30859
+ /** SHA-256 of normalized relevance filters; null for chronological order. */
30860
+ filterHash: string | null;
30809
30861
  };
30810
30862
  export const SESSION_DISCOVERY_GOAL_MAX_CHARS = 600;
30811
30863
  export const SESSION_DISCOVERY_MESSAGE_MAX_CHARS = 600;
@@ -30830,12 +30882,15 @@ export type SessionDiscoverySummary = {
30830
30882
  preview: string | null;
30831
30883
  previewOriginalChars: number | null;
30832
30884
  } | null;
30885
+ workDiscovery: WorkDiscoveryProjection;
30833
30886
  createdAt: string;
30834
30887
  updatedAt: string;
30835
30888
  /** Internal exact revision key; omitted from the MCP row projection. */
30836
30889
  sortRevision: string;
30837
30890
  /** Internal exact keyset timestamp; omitted from the MCP row projection. */
30838
30891
  sortAt: string;
30892
+ /** Internal stable relevance class; omitted from public row projections. */
30893
+ sortRank: number | null;
30839
30894
  };
30840
30895
 
30841
30896
  function projectSessionDiscoveryControlForRelatedAccess(
@@ -30894,6 +30949,700 @@ async function readWorkspaceSessionActivityRevision(
30894
30949
  return normalizeSessionActivityRevision(revision, "snapshot revision");
30895
30950
  }
30896
30951
 
30952
+ const SESSION_DISCOVERY_QUERY_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u;
30953
+
30954
+ type NormalizedSessionDiscoveryFilters = {
30955
+ query: string | null;
30956
+ statuses: SessionStatus[];
30957
+ activeOnly: boolean;
30958
+ recentHours: number | null;
30959
+ rootSessionId: string | null;
30960
+ parentSessionId: string | null | undefined;
30961
+ subject: WorkClaimSubjectFilter | null;
30962
+ };
30963
+
30964
+ type SessionDiscoveryPageRow = {
30965
+ id: string | null;
30966
+ title: string | null;
30967
+ titleOriginalChars: number | string | null;
30968
+ parentSessionId: string | null;
30969
+ rootSessionId: string | null;
30970
+ nestedAgentDepth: number | string | null;
30971
+ status: string | null;
30972
+ createdAt: Date | string | null;
30973
+ updatedAt: Date | string | null;
30974
+ sortRevision: string | null;
30975
+ sortAt: string | null;
30976
+ sortRank: number | string | null;
30977
+ matchClass: string | null;
30978
+ matchedField: string | null;
30979
+ scoreBand: string | null;
30980
+ matchedClaimId: string | null;
30981
+ total: number | string;
30982
+ };
30983
+
30984
+ type SessionDiscoveryClaimRow = {
30985
+ sessionId: string;
30986
+ id: string;
30987
+ subjectNamespace: string;
30988
+ subjectType: string;
30989
+ canonicalKey: string;
30990
+ displayLabel: string | null;
30991
+ role: string;
30992
+ state: string;
30993
+ revision: number | string;
30994
+ provenance: string;
30995
+ versionKind: string | null;
30996
+ versionValue: string | null;
30997
+ observedAt: Date | string;
30998
+ updatedAt: Date | string;
30999
+ settledAt: Date | string | null;
31000
+ availableCount: number | string;
31001
+ };
31002
+
31003
+ function normalizeSessionDiscoveryQuery(value: string | undefined): string | null {
31004
+ if (value === undefined) return null;
31005
+ const canonical = value.normalize("NFKC");
31006
+ if (SESSION_DISCOVERY_QUERY_CONTROL_CHARACTERS.test(canonical)) {
31007
+ throw new Error("sessions_list query must not contain control characters");
31008
+ }
31009
+ const normalized = canonical.trim().replace(/\s+/gu, " ").toLowerCase();
31010
+ if (!normalized) return null;
31011
+ if (Array.from(normalized).length > WORK_DISCOVERY_QUERY_MAX_CHARS) {
31012
+ throw new Error(
31013
+ `sessions_list query must be at most ${WORK_DISCOVERY_QUERY_MAX_CHARS} characters`,
31014
+ );
31015
+ }
31016
+ return normalized;
31017
+ }
31018
+
31019
+ function normalizeSessionDiscoverySubject(
31020
+ value: WorkClaimSubjectFilter | undefined,
31021
+ ): WorkClaimSubjectFilter | null {
31022
+ if (!value) return null;
31023
+ return {
31024
+ namespace: WorkClaimNamespace.parse(normalizeWorkClaimNamespace(value.namespace)),
31025
+ type: WorkClaimSubjectType.parse(value.type),
31026
+ canonicalKey: WorkClaimCanonicalKey.parse(normalizeWorkClaimCanonicalKey(value.canonicalKey)),
31027
+ };
31028
+ }
31029
+
31030
+ function normalizeSessionDiscoveryFilters(options: {
31031
+ query?: string;
31032
+ search?: string;
31033
+ statuses?: SessionStatus[];
31034
+ activeOnly?: boolean;
31035
+ recentHours?: number;
31036
+ rootSessionId?: string;
31037
+ parentSessionId?: string | null;
31038
+ subject?: WorkClaimSubjectFilter;
31039
+ }): NormalizedSessionDiscoveryFilters {
31040
+ const query = normalizeSessionDiscoveryQuery(options.query ?? options.search);
31041
+ if (
31042
+ options.query !== undefined &&
31043
+ options.search !== undefined &&
31044
+ normalizeSessionDiscoveryQuery(options.query) !== normalizeSessionDiscoveryQuery(options.search)
31045
+ ) {
31046
+ throw new Error("sessions_list query and legacy search filter do not match");
31047
+ }
31048
+ const statuses = [...new Set(options.statuses ?? [])]
31049
+ .map((status) => SessionStatusSchema.parse(status))
31050
+ .sort();
31051
+ const recentHours = options.recentHours ?? null;
31052
+ if (
31053
+ recentHours !== null &&
31054
+ (!Number.isSafeInteger(recentHours) ||
31055
+ recentHours < 1 ||
31056
+ recentHours > WORK_DISCOVERY_RECENT_HOURS_MAX)
31057
+ ) {
31058
+ throw new Error(
31059
+ `sessions_list recentHours must be an integer between 1 and ${WORK_DISCOVERY_RECENT_HOURS_MAX}`,
31060
+ );
31061
+ }
31062
+ const subject = normalizeSessionDiscoverySubject(options.subject);
31063
+ if (query && subject) {
31064
+ throw new Error("sessions_list query cannot be combined with an exact subject filter");
31065
+ }
31066
+ return {
31067
+ query,
31068
+ statuses,
31069
+ activeOnly: options.activeOnly === true,
31070
+ recentHours,
31071
+ rootSessionId: options.rootSessionId ?? null,
31072
+ parentSessionId: Object.prototype.hasOwnProperty.call(options, "parentSessionId")
31073
+ ? (options.parentSessionId ?? null)
31074
+ : undefined,
31075
+ subject,
31076
+ };
31077
+ }
31078
+
31079
+ function sessionDiscoveryFilterHash(filters: NormalizedSessionDiscoveryFilters): string {
31080
+ return createHash("sha256")
31081
+ .update(
31082
+ JSON.stringify({
31083
+ query: filters.query,
31084
+ statuses: filters.statuses,
31085
+ activeOnly: filters.activeOnly,
31086
+ recentHours: filters.recentHours,
31087
+ rootSessionId: filters.rootSessionId,
31088
+ parentSessionId:
31089
+ filters.parentSessionId === undefined ? { any: true } : filters.parentSessionId,
31090
+ subject: filters.subject,
31091
+ }),
31092
+ )
31093
+ .digest("hex");
31094
+ }
31095
+
31096
+ function sessionDiscoverySubjectDigest(subject: WorkClaimSubjectFilter): string {
31097
+ return createHash("sha256")
31098
+ .update(`${subject.namespace}\u001f${subject.type}\u001f${subject.canonicalKey}`, "utf8")
31099
+ .digest("hex");
31100
+ }
31101
+
31102
+ function sessionDiscoveryIso(value: Date | string): string {
31103
+ return (value instanceof Date ? value : new Date(value)).toISOString();
31104
+ }
31105
+
31106
+ async function selectSessionDiscoveryPageRows(
31107
+ db: Database,
31108
+ workspaceId: string,
31109
+ options: {
31110
+ limit: number;
31111
+ orderBy: SessionDiscoveryOrderBy;
31112
+ cursor?: SessionDiscoveryCursor;
31113
+ snapshotAt: string;
31114
+ snapshotRevision: string;
31115
+ updatedAfter: string | null;
31116
+ subjectId?: string;
31117
+ authorizationScope?: SessionAuthorizationListScope;
31118
+ filters: NormalizedSessionDiscoveryFilters;
31119
+ },
31120
+ ): Promise<{ rows: SessionDiscoveryPageRow[]; total: number }> {
31121
+ const authorizationFilters: SQL[] = [eq(schema.sessions.workspaceId, workspaceId)];
31122
+ if (options.subjectId) {
31123
+ authorizationFilters.push(sql`not exists (
31124
+ select 1
31125
+ from ${schema.slackInteractions} private_slack_interaction
31126
+ where private_slack_interaction.workspace_id = ${schema.sessions.workspaceId}
31127
+ and private_slack_interaction.session_reservation_id = ${schema.sessions.rootSessionId}
31128
+ and private_slack_interaction.visibility = 'private'
31129
+ and private_slack_interaction.owning_subject_id <> ${options.subjectId}
31130
+ )`);
31131
+ } else {
31132
+ // Missing subject context must fail closed for Slack-private roots. Public
31133
+ // and ordinary workspace-shared discovery remains available to internal
31134
+ // callers that do not carry a human subject.
31135
+ authorizationFilters.push(sql`not exists (
31136
+ select 1
31137
+ from ${schema.slackInteractions} private_slack_interaction
31138
+ where private_slack_interaction.workspace_id = ${schema.sessions.workspaceId}
31139
+ and private_slack_interaction.session_reservation_id = ${schema.sessions.rootSessionId}
31140
+ and private_slack_interaction.visibility = 'private'
31141
+ )`);
31142
+ }
31143
+ if (options.authorizationScope) {
31144
+ authorizationFilters.push(sessionAuthorizationScopeFilter(options.authorizationScope));
31145
+ }
31146
+ if (options.filters.statuses.length > 0) {
31147
+ authorizationFilters.push(inArray(schema.sessions.status, options.filters.statuses));
31148
+ }
31149
+ if (options.filters.activeOnly) {
31150
+ authorizationFilters.push(notInArray(schema.sessions.status, ["failed", "cancelled"]));
31151
+ }
31152
+ if (options.filters.recentHours !== null) {
31153
+ authorizationFilters.push(
31154
+ sql`${schema.sessions.updatedAt} >= ${options.snapshotAt}::text::timestamptz
31155
+ - (${options.filters.recentHours}::integer * interval '1 hour')`,
31156
+ );
31157
+ }
31158
+ if (options.filters.rootSessionId) {
31159
+ authorizationFilters.push(eq(schema.sessions.rootSessionId, options.filters.rootSessionId));
31160
+ }
31161
+ if (options.filters.parentSessionId !== undefined) {
31162
+ authorizationFilters.push(
31163
+ options.filters.parentSessionId === null
31164
+ ? isNull(schema.sessions.parentSessionId)
31165
+ : eq(schema.sessions.parentSessionId, options.filters.parentSessionId),
31166
+ );
31167
+ }
31168
+ authorizationFilters.push(
31169
+ options.orderBy === "createdAt"
31170
+ ? sql`${schema.sessions.createdAt} <= ${options.snapshotAt}::text::timestamptz`
31171
+ : sql`${schema.sessions.activityRevision} <= ${options.snapshotRevision}::text::bigint`,
31172
+ );
31173
+ if (options.updatedAfter !== null) {
31174
+ authorizationFilters.push(
31175
+ sql`${schema.sessions.activityRevision} > ${options.updatedAfter}::text::bigint`,
31176
+ );
31177
+ }
31178
+ const authorizedPredicate = and(...authorizationFilters) ?? sql`false`;
31179
+ const claimStatePredicate =
31180
+ options.filters.activeOnly || options.filters.recentHours === null
31181
+ ? sql`${schema.sessionWorkClaims.state} = 'active'`
31182
+ : sql`(${schema.sessionWorkClaims.state} = 'active' or
31183
+ ${schema.sessionWorkClaims.updatedAt} >= ${options.snapshotAt}::text::timestamptz
31184
+ - (${options.filters.recentHours}::integer * interval '1 hour'))`;
31185
+ const subjectDigest = options.filters.subject
31186
+ ? sessionDiscoverySubjectDigest(options.filters.subject)
31187
+ : null;
31188
+ const subjectScopePredicate = options.filters.subject
31189
+ ? sql`exists (
31190
+ select 1
31191
+ from ${schema.sessionWorkClaims}
31192
+ where ${schema.sessionWorkClaims.workspaceId} = ${workspaceId}
31193
+ and ${schema.sessionWorkClaims.sessionId} = authorized_sessions.id
31194
+ and ${schema.sessionWorkClaims.subjectNamespace} = ${options.filters.subject.namespace}
31195
+ and ${schema.sessionWorkClaims.subjectType} = ${options.filters.subject.type}
31196
+ and ${schema.sessionWorkClaims.subjectDigest} = ${subjectDigest}
31197
+ and ${claimStatePredicate}
31198
+ )`
31199
+ : sql`true`;
31200
+
31201
+ const rankingCtes = options.filters.subject
31202
+ ? sql`,
31203
+ match_candidates as materialized (
31204
+ select
31205
+ subject_scoped_sessions.id as session_id,
31206
+ 0::integer as match_rank,
31207
+ 'exact_subject'::text as match_class,
31208
+ 'subject'::text as matched_field,
31209
+ 'exact'::text as score_band,
31210
+ ${schema.sessionWorkClaims.id} as matched_claim_id,
31211
+ ${schema.sessionWorkClaims.updatedAt} as matched_claim_updated_at
31212
+ from subject_scoped_sessions
31213
+ join ${schema.sessionWorkClaims}
31214
+ on ${schema.sessionWorkClaims.workspaceId} = ${workspaceId}
31215
+ and ${schema.sessionWorkClaims.sessionId} = subject_scoped_sessions.id
31216
+ and ${schema.sessionWorkClaims.subjectNamespace} = ${options.filters.subject.namespace}
31217
+ and ${schema.sessionWorkClaims.subjectType} = ${options.filters.subject.type}
31218
+ and ${schema.sessionWorkClaims.subjectDigest} = ${subjectDigest}
31219
+ and ${claimStatePredicate}
31220
+ ),
31221
+ best_matches as materialized (
31222
+ select distinct on (session_id)
31223
+ session_id, match_rank, match_class, matched_field, score_band, matched_claim_id
31224
+ from match_candidates
31225
+ order by session_id, match_rank, matched_claim_updated_at desc, matched_claim_id desc
31226
+ )`
31227
+ : options.filters.query
31228
+ ? sql`,
31229
+ search_input as materialized (
31230
+ select
31231
+ lower(${options.filters.query}::text) as normalized_query,
31232
+ websearch_to_tsquery('simple', ${options.filters.query}::text) as ts_query
31233
+ ),
31234
+ match_candidates as materialized (
31235
+ select
31236
+ subject_scoped_sessions.id as session_id,
31237
+ case
31238
+ when lower(search_session.title) = search_input.normalized_query then 10
31239
+ when starts_with(lower(search_session.title), search_input.normalized_query) then 11
31240
+ else 12
31241
+ end::integer as match_rank,
31242
+ 'title'::text as match_class,
31243
+ 'title'::text as matched_field,
31244
+ case
31245
+ when lower(search_session.title) = search_input.normalized_query then 'exact'
31246
+ else 'strong'
31247
+ end::text as score_band,
31248
+ null::uuid as matched_claim_id,
31249
+ null::timestamptz as matched_claim_updated_at
31250
+ from subject_scoped_sessions
31251
+ join ${schema.sessions} search_session
31252
+ on search_session.workspace_id = ${workspaceId}
31253
+ and search_session.id = subject_scoped_sessions.id
31254
+ cross join search_input
31255
+ where search_session.title is not null
31256
+ and (
31257
+ lower(search_session.title) = search_input.normalized_query
31258
+ or starts_with(lower(search_session.title), search_input.normalized_query)
31259
+ or to_tsvector('simple', search_session.title) @@ search_input.ts_query
31260
+ )
31261
+
31262
+ union all
31263
+
31264
+ select
31265
+ subject_scoped_sessions.id as session_id,
31266
+ case
31267
+ when lower(${schema.sessionGoals.text}) = search_input.normalized_query then 20
31268
+ when starts_with(
31269
+ lower(${schema.sessionGoals.text}), search_input.normalized_query
31270
+ ) then 21
31271
+ else 22
31272
+ end::integer as match_rank,
31273
+ 'goal'::text as match_class,
31274
+ 'goal'::text as matched_field,
31275
+ case
31276
+ when lower(${schema.sessionGoals.text}) = search_input.normalized_query then 'exact'
31277
+ else 'strong'
31278
+ end::text as score_band,
31279
+ null::uuid as matched_claim_id,
31280
+ null::timestamptz as matched_claim_updated_at
31281
+ from subject_scoped_sessions
31282
+ join ${schema.sessionGoals}
31283
+ on ${schema.sessionGoals.workspaceId} = ${workspaceId}
31284
+ and ${schema.sessionGoals.sessionId} = subject_scoped_sessions.id
31285
+ and ${schema.sessionGoals.status} = 'active'
31286
+ cross join search_input
31287
+ where lower(${schema.sessionGoals.text}) = search_input.normalized_query
31288
+ or starts_with(lower(${schema.sessionGoals.text}), search_input.normalized_query)
31289
+ or to_tsvector('simple', ${schema.sessionGoals.text}) @@ search_input.ts_query
31290
+
31291
+ union all
31292
+
31293
+ select
31294
+ subject_scoped_sessions.id as session_id,
31295
+ (
31296
+ case when ${schema.sessionWorkClaims.state} = 'active' then 30 else 40 end
31297
+ + case
31298
+ when lower(${schema.sessionWorkClaims.canonicalKey}) = search_input.normalized_query
31299
+ or lower(coalesce(${schema.sessionWorkClaims.displayLabel}, '')) = search_input.normalized_query then 0
31300
+ when starts_with(
31301
+ lower(${schema.sessionWorkClaims.canonicalKey}),
31302
+ search_input.normalized_query
31303
+ ) or starts_with(
31304
+ lower(coalesce(${schema.sessionWorkClaims.displayLabel}, '')),
31305
+ search_input.normalized_query
31306
+ ) then 1
31307
+ else 2
31308
+ end
31309
+ )::integer as match_rank,
31310
+ 'fuzzy'::text as match_class,
31311
+ case
31312
+ when lower(coalesce(${schema.sessionWorkClaims.displayLabel}, '')) = search_input.normalized_query
31313
+ or starts_with(
31314
+ lower(coalesce(${schema.sessionWorkClaims.displayLabel}, '')),
31315
+ search_input.normalized_query
31316
+ )
31317
+ or to_tsvector('simple', coalesce(${schema.sessionWorkClaims.displayLabel}, '')) @@ search_input.ts_query
31318
+ then 'claim_label'
31319
+ else 'claim_key'
31320
+ end::text as matched_field,
31321
+ case
31322
+ when lower(${schema.sessionWorkClaims.canonicalKey}) = search_input.normalized_query
31323
+ or lower(coalesce(${schema.sessionWorkClaims.displayLabel}, '')) = search_input.normalized_query then 'exact'
31324
+ when starts_with(
31325
+ lower(${schema.sessionWorkClaims.canonicalKey}), search_input.normalized_query
31326
+ ) or starts_with(
31327
+ lower(coalesce(${schema.sessionWorkClaims.displayLabel}, '')),
31328
+ search_input.normalized_query
31329
+ ) then 'strong'
31330
+ else 'related'
31331
+ end::text as score_band,
31332
+ ${schema.sessionWorkClaims.id} as matched_claim_id,
31333
+ ${schema.sessionWorkClaims.updatedAt} as matched_claim_updated_at
31334
+ from subject_scoped_sessions
31335
+ join ${schema.sessionWorkClaims}
31336
+ on ${schema.sessionWorkClaims.workspaceId} = ${workspaceId}
31337
+ and ${schema.sessionWorkClaims.sessionId} = subject_scoped_sessions.id
31338
+ and ${claimStatePredicate}
31339
+ cross join search_input
31340
+ where lower(${schema.sessionWorkClaims.canonicalKey}) = search_input.normalized_query
31341
+ or starts_with(
31342
+ lower(${schema.sessionWorkClaims.canonicalKey}),
31343
+ search_input.normalized_query
31344
+ )
31345
+ or to_tsvector(
31346
+ 'simple',
31347
+ ${schema.sessionWorkClaims.canonicalKey} || ' '
31348
+ || coalesce(${schema.sessionWorkClaims.displayLabel}, '')
31349
+ ) @@ search_input.ts_query
31350
+ or lower(coalesce(${schema.sessionWorkClaims.displayLabel}, '')) = search_input.normalized_query
31351
+ or starts_with(
31352
+ lower(coalesce(${schema.sessionWorkClaims.displayLabel}, '')),
31353
+ search_input.normalized_query
31354
+ )
31355
+ ),
31356
+ best_matches as materialized (
31357
+ select distinct on (session_id)
31358
+ session_id, match_rank, match_class, matched_field, score_band, matched_claim_id
31359
+ from match_candidates
31360
+ order by session_id, match_rank, matched_claim_updated_at desc nulls last,
31361
+ matched_claim_id desc nulls last
31362
+ )`
31363
+ : sql``;
31364
+ const rankedCte =
31365
+ options.orderBy === "relevance"
31366
+ ? sql`,
31367
+ ranked_sessions as materialized (
31368
+ select
31369
+ subject_scoped_sessions.*,
31370
+ best_matches.match_rank,
31371
+ best_matches.match_class,
31372
+ best_matches.matched_field,
31373
+ best_matches.score_band,
31374
+ best_matches.matched_claim_id
31375
+ from subject_scoped_sessions
31376
+ join best_matches on best_matches.session_id = subject_scoped_sessions.id
31377
+ )`
31378
+ : sql`,
31379
+ ranked_sessions as materialized (
31380
+ select
31381
+ subject_scoped_sessions.*,
31382
+ null::integer as match_rank,
31383
+ null::text as match_class,
31384
+ null::text as matched_field,
31385
+ null::text as score_band,
31386
+ null::uuid as matched_claim_id
31387
+ from subject_scoped_sessions
31388
+ )`;
31389
+ const cursorPredicate = !options.cursor
31390
+ ? sql`true`
31391
+ : options.orderBy === "relevance"
31392
+ ? sql`(
31393
+ ranked_sessions.match_rank > ${options.cursor.sortRank}::integer
31394
+ or (
31395
+ ranked_sessions.match_rank = ${options.cursor.sortRank}::integer
31396
+ and (
31397
+ ranked_sessions.activity_revision < ${options.cursor.sortRevision}::text::bigint
31398
+ or (
31399
+ ranked_sessions.activity_revision = ${options.cursor.sortRevision}::text::bigint
31400
+ and (
31401
+ ranked_sessions.updated_at < ${options.cursor.sortAt}::text::timestamptz
31402
+ or (
31403
+ ranked_sessions.updated_at = ${options.cursor.sortAt}::text::timestamptz
31404
+ and ranked_sessions.id < ${options.cursor.id}::uuid
31405
+ )
31406
+ )
31407
+ )
31408
+ )
31409
+ )
31410
+ )`
31411
+ : options.orderBy === "updatedAt"
31412
+ ? sql`(
31413
+ ranked_sessions.activity_revision < ${options.cursor.sortRevision}::text::bigint
31414
+ or (
31415
+ ranked_sessions.activity_revision = ${options.cursor.sortRevision}::text::bigint
31416
+ and (
31417
+ ranked_sessions.updated_at < ${options.cursor.sortAt}::text::timestamptz
31418
+ or (
31419
+ ranked_sessions.updated_at = ${options.cursor.sortAt}::text::timestamptz
31420
+ and ranked_sessions.id < ${options.cursor.id}::uuid
31421
+ )
31422
+ )
31423
+ )
31424
+ )`
31425
+ : sql`(
31426
+ ranked_sessions.created_at < ${options.cursor.sortAt}::text::timestamptz
31427
+ or (
31428
+ ranked_sessions.created_at = ${options.cursor.sortAt}::text::timestamptz
31429
+ and ranked_sessions.id < ${options.cursor.id}::uuid
31430
+ )
31431
+ )`;
31432
+ const pageOrder =
31433
+ options.orderBy === "relevance"
31434
+ ? sql`ranked_sessions.match_rank asc, ranked_sessions.activity_revision desc,
31435
+ ranked_sessions.updated_at desc, ranked_sessions.id desc`
31436
+ : options.orderBy === "updatedAt"
31437
+ ? sql`ranked_sessions.activity_revision desc, ranked_sessions.updated_at desc,
31438
+ ranked_sessions.id desc`
31439
+ : sql`ranked_sessions.created_at desc, ranked_sessions.id desc`;
31440
+ const outerOrder =
31441
+ options.orderBy === "relevance"
31442
+ ? sql`page."sortRank" asc nulls last, page."sortRevision"::bigint desc nulls last,
31443
+ page."sortAt"::timestamptz desc nulls last, page.id desc nulls last`
31444
+ : options.orderBy === "updatedAt"
31445
+ ? sql`page."sortRevision"::bigint desc nulls last,
31446
+ page."sortAt"::timestamptz desc nulls last, page.id desc nulls last`
31447
+ : sql`page."sortAt"::timestamptz desc nulls last, page.id desc nulls last`;
31448
+ const sortRevision =
31449
+ options.orderBy === "createdAt" ? sql`'0'::text` : sql`ranked_sessions.activity_revision::text`;
31450
+ const sortAt =
31451
+ options.orderBy === "createdAt"
31452
+ ? sql`to_char(ranked_sessions.created_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`
31453
+ : sql`to_char(ranked_sessions.updated_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`;
31454
+
31455
+ const result = await rawRows<SessionDiscoveryPageRow>(
31456
+ db,
31457
+ sql`
31458
+ with authorized_sessions as materialized (
31459
+ select
31460
+ ${schema.sessions.id} as id,
31461
+ ${schema.sessions.title} as title,
31462
+ ${schema.sessions.parentSessionId} as parent_session_id,
31463
+ ${schema.sessions.rootSessionId} as root_session_id,
31464
+ ${schema.sessions.nestedAgentDepth} as nested_agent_depth,
31465
+ ${schema.sessions.status} as status,
31466
+ ${schema.sessions.createdAt} as created_at,
31467
+ ${schema.sessions.updatedAt} as updated_at,
31468
+ ${schema.sessions.activityRevision} as activity_revision
31469
+ from ${schema.sessions}
31470
+ where ${authorizedPredicate}
31471
+ ),
31472
+ subject_scoped_sessions as materialized (
31473
+ select * from authorized_sessions where ${subjectScopePredicate}
31474
+ )
31475
+ ${rankingCtes}
31476
+ ${rankedCte}
31477
+ select
31478
+ page.id,
31479
+ page.title,
31480
+ page."titleOriginalChars",
31481
+ page."parentSessionId",
31482
+ page."rootSessionId",
31483
+ page."nestedAgentDepth",
31484
+ page.status,
31485
+ page."createdAt",
31486
+ page."updatedAt",
31487
+ page."sortRevision",
31488
+ page."sortAt",
31489
+ page."sortRank",
31490
+ page."matchClass",
31491
+ page."matchedField",
31492
+ page."scoreBand",
31493
+ page."matchedClaimId",
31494
+ totals.total
31495
+ from (select count(*)::integer as total from ranked_sessions) totals
31496
+ left join lateral (
31497
+ select
31498
+ ranked_sessions.id,
31499
+ left(ranked_sessions.title, ${SESSION_DISCOVERY_CONTROL_TITLE_MAX_CHARS}) as title,
31500
+ char_length(ranked_sessions.title)::integer as "titleOriginalChars",
31501
+ ranked_sessions.parent_session_id as "parentSessionId",
31502
+ ranked_sessions.root_session_id as "rootSessionId",
31503
+ ranked_sessions.nested_agent_depth as "nestedAgentDepth",
31504
+ ranked_sessions.status,
31505
+ ranked_sessions.created_at as "createdAt",
31506
+ ranked_sessions.updated_at as "updatedAt",
31507
+ ${sortRevision} as "sortRevision",
31508
+ ${sortAt} as "sortAt",
31509
+ ranked_sessions.match_rank as "sortRank",
31510
+ ranked_sessions.match_class as "matchClass",
31511
+ ranked_sessions.matched_field as "matchedField",
31512
+ ranked_sessions.score_band as "scoreBand",
31513
+ ranked_sessions.matched_claim_id as "matchedClaimId"
31514
+ from ranked_sessions
31515
+ where ${cursorPredicate}
31516
+ order by ${pageOrder}
31517
+ limit ${options.limit + 1}
31518
+ ) page on true
31519
+ order by ${outerOrder}
31520
+ `,
31521
+ );
31522
+ const total = Number(result[0]?.total ?? 0);
31523
+ return { rows: result.filter((row) => row.id !== null), total };
31524
+ }
31525
+
31526
+ async function sessionDiscoveryClaimsForSessions(
31527
+ db: Database,
31528
+ workspaceId: string,
31529
+ sessionIds: string[],
31530
+ options: {
31531
+ snapshotAt: string;
31532
+ activeOnly: boolean;
31533
+ recentHours: number | null;
31534
+ limit: number;
31535
+ matchedClaimIds: ReadonlyMap<string, string | null>;
31536
+ },
31537
+ ): Promise<Map<string, { claims: WorkClaimDiscoverySummary[]; truncated: boolean }>> {
31538
+ if (sessionIds.length === 0 || options.limit < 1) return new Map();
31539
+ const claimStatePredicate =
31540
+ options.activeOnly || options.recentHours === null
31541
+ ? sql`${schema.sessionWorkClaims.state} = 'active'`
31542
+ : sql`(${schema.sessionWorkClaims.state} = 'active' or
31543
+ ${schema.sessionWorkClaims.updatedAt} >= ${options.snapshotAt}::text::timestamptz
31544
+ - (${options.recentHours}::integer * interval '1 hour'))`;
31545
+ const rows = await rawRows<SessionDiscoveryClaimRow>(
31546
+ db,
31547
+ sql`
31548
+ with requested_sessions(session_id, matched_claim_id) as (
31549
+ values ${sql.join(
31550
+ sessionIds.map(
31551
+ (sessionId) =>
31552
+ sql`(${sessionId}::uuid, ${options.matchedClaimIds.get(sessionId) ?? null}::uuid)`,
31553
+ ),
31554
+ sql`, `,
31555
+ )}
31556
+ )
31557
+ select
31558
+ requested_sessions.session_id as "sessionId",
31559
+ claim.id,
31560
+ claim.subject_namespace as "subjectNamespace",
31561
+ claim.subject_type as "subjectType",
31562
+ claim.canonical_key as "canonicalKey",
31563
+ claim.display_label as "displayLabel",
31564
+ claim.role,
31565
+ claim.state,
31566
+ claim.revision,
31567
+ claim.provenance,
31568
+ claim.version_kind as "versionKind",
31569
+ claim.version_value as "versionValue",
31570
+ claim.observed_at as "observedAt",
31571
+ claim.updated_at as "updatedAt",
31572
+ claim.settled_at as "settledAt",
31573
+ claim.available_count as "availableCount"
31574
+ from requested_sessions
31575
+ cross join lateral (
31576
+ select
31577
+ ${schema.sessionWorkClaims.id} as id,
31578
+ ${schema.sessionWorkClaims.subjectNamespace} as subject_namespace,
31579
+ ${schema.sessionWorkClaims.subjectType} as subject_type,
31580
+ ${schema.sessionWorkClaims.canonicalKey} as canonical_key,
31581
+ ${schema.sessionWorkClaims.displayLabel} as display_label,
31582
+ ${schema.sessionWorkClaims.role} as role,
31583
+ ${schema.sessionWorkClaims.state} as state,
31584
+ ${schema.sessionWorkClaims.revision} as revision,
31585
+ ${schema.sessionWorkClaims.provenance} as provenance,
31586
+ ${schema.sessionWorkClaims.versionKind} as version_kind,
31587
+ ${schema.sessionWorkClaims.versionValue} as version_value,
31588
+ ${schema.sessionWorkClaims.observedAt} as observed_at,
31589
+ ${schema.sessionWorkClaims.updatedAt} as updated_at,
31590
+ ${schema.sessionWorkClaims.settledAt} as settled_at,
31591
+ count(*) over()::integer as available_count
31592
+ from ${schema.sessionWorkClaims}
31593
+ where ${schema.sessionWorkClaims.workspaceId} = ${workspaceId}
31594
+ and ${schema.sessionWorkClaims.sessionId} = requested_sessions.session_id
31595
+ and ${claimStatePredicate}
31596
+ order by
31597
+ (
31598
+ requested_sessions.matched_claim_id is not null
31599
+ and ${schema.sessionWorkClaims.id} = requested_sessions.matched_claim_id
31600
+ ) desc,
31601
+ (${schema.sessionWorkClaims.state} = 'active') desc,
31602
+ ${schema.sessionWorkClaims.updatedAt} desc,
31603
+ ${schema.sessionWorkClaims.id} desc
31604
+ limit ${options.limit}
31605
+ ) claim
31606
+ order by requested_sessions.session_id,
31607
+ (
31608
+ requested_sessions.matched_claim_id is not null
31609
+ and claim.id = requested_sessions.matched_claim_id
31610
+ ) desc,
31611
+ (claim.state = 'active') desc, claim.updated_at desc, claim.id desc
31612
+ `,
31613
+ );
31614
+ const grouped = new Map<string, { claims: WorkClaimDiscoverySummary[]; truncated: boolean }>();
31615
+ for (const row of rows) {
31616
+ const entry = grouped.get(row.sessionId) ?? { claims: [], truncated: false };
31617
+ entry.claims.push(
31618
+ WorkClaimDiscoverySummarySchema.parse({
31619
+ id: row.id,
31620
+ sessionId: row.sessionId,
31621
+ subject: {
31622
+ namespace: row.subjectNamespace,
31623
+ type: row.subjectType,
31624
+ canonicalKey: row.canonicalKey,
31625
+ displayLabel: row.displayLabel,
31626
+ },
31627
+ role: row.role,
31628
+ state: row.state,
31629
+ revision: Number(row.revision),
31630
+ provenance: row.provenance,
31631
+ version:
31632
+ row.versionKind === null || row.versionValue === null
31633
+ ? null
31634
+ : { kind: row.versionKind, value: row.versionValue },
31635
+ observedAt: sessionDiscoveryIso(row.observedAt),
31636
+ updatedAt: sessionDiscoveryIso(row.updatedAt),
31637
+ settledAt: row.settledAt === null ? null : sessionDiscoveryIso(row.settledAt),
31638
+ }),
31639
+ );
31640
+ entry.truncated = Number(row.availableCount) > options.limit;
31641
+ grouped.set(row.sessionId, entry);
31642
+ }
31643
+ return grouped;
31644
+ }
31645
+
30897
31646
  /**
30898
31647
  * Excludes a human/API `user.message` whose accepted turn was never claimed.
30899
31648
  * Such a prompt was never model input: it is waiting work (represented by
@@ -30957,7 +31706,17 @@ export async function listSessionDiscoverySummaries(
30957
31706
  orderBy?: SessionDiscoveryOrderBy;
30958
31707
  updatedAfter?: string;
30959
31708
  parentSessionId?: string | null;
31709
+ rootSessionId?: string;
31710
+ query?: string;
31711
+ /** @deprecated use query; retained for the existing topology route. */
30960
31712
  search?: string;
31713
+ statuses?: SessionStatus[];
31714
+ activeOnly?: boolean;
31715
+ recentHours?: number;
31716
+ subject?: WorkClaimSubjectFilter;
31717
+ claimLimit?: number;
31718
+ /** Skip advisory claim reads for an operator-disabled discovery rollout. */
31719
+ includeWorkDiscovery?: boolean;
30961
31720
  subjectId?: string;
30962
31721
  authorizationScope?: SessionAuthorizationListScope;
30963
31722
  },
@@ -30971,330 +31730,306 @@ export async function listSessionDiscoverySummaries(
30971
31730
  snapshotRevision: string;
30972
31731
  updatedThrough: string | null;
30973
31732
  updatedAfter: string | null;
31733
+ /** Internal relevance cursor binding; omitted from row projections. */
31734
+ filterHash: string | null;
30974
31735
  }> {
30975
31736
  const limit = Math.max(1, Math.min(100, Math.floor(options.limit)));
30976
- const orderBy = options.orderBy ?? options.cursor?.orderBy ?? "createdAt";
30977
- return await withWorkspaceRls(
30978
- db,
30979
- workspaceId,
30980
- async (scopedDb) => {
30981
- const requestedUpdatedAfter = options.updatedAfter ?? options.cursor?.updatedAfter ?? null;
30982
- const updatedAfter =
30983
- requestedUpdatedAfter === null
30984
- ? null
30985
- : normalizeSessionActivityRevision(requestedUpdatedAfter, "updatedAfter");
30986
- if (options.cursor?.orderBy !== undefined && options.cursor.orderBy !== orderBy) {
30987
- throw new Error("sessions_list cursor order does not match the request");
30988
- }
30989
- if (options.cursor && options.cursor.updatedAfter !== updatedAfter) {
30990
- throw new Error("sessions_list cursor incremental filter does not match the request");
30991
- }
30992
- if (updatedAfter !== null && orderBy !== "updatedAt") {
30993
- throw new Error("sessions_list updatedAfter requires orderBy=updatedAt");
30994
- }
30995
- const snapshotAt =
30996
- options.cursor?.snapshotAt ??
30997
- (
30998
- await rawRows<{ value: string }>(
30999
- scopedDb,
31000
- // The transaction-start cutoff is no later than any statement or
31001
- // repeatable-read snapshot used below. Later rows therefore belong
31002
- // to the next traversal instead of entering this cursor mid-scan.
31003
- sql`select to_char(transaction_timestamp() at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') as value`,
31004
- )
31005
- )[0]!.value;
31006
- const snapshotRevision =
31007
- orderBy === "updatedAt"
31008
- ? options.cursor?.snapshotRevision !== undefined
31009
- ? normalizeSessionActivityRevision(
31010
- options.cursor.snapshotRevision,
31011
- "cursor snapshot revision",
31012
- )
31013
- : await readWorkspaceSessionActivityRevision(scopedDb, workspaceId)
31014
- : "0";
31015
- const cursorSortRevision = options.cursor
31016
- ? normalizeSessionActivityRevision(options.cursor.sortRevision, "cursor sort revision")
31017
- : null;
31737
+ const claimLimit = Math.max(
31738
+ 1,
31739
+ Math.min(
31740
+ WORK_CLAIM_DISCOVERY_LIMIT,
31741
+ Math.floor(options.claimLimit ?? WORK_CLAIM_DISCOVERY_DEFAULT_LIMIT),
31742
+ ),
31743
+ );
31744
+ const filters = normalizeSessionDiscoveryFilters(options);
31745
+ const relevanceRequested = filters.query !== null || filters.subject !== null;
31746
+ const orderBy =
31747
+ options.orderBy ?? options.cursor?.orderBy ?? (relevanceRequested ? "relevance" : "createdAt");
31748
+ if (relevanceRequested && orderBy !== "relevance") {
31749
+ throw new Error("sessions_list query and exact subject filters require relevance order");
31750
+ }
31751
+ if (!relevanceRequested && orderBy === "relevance") {
31752
+ throw new Error("sessions_list relevance order requires query or an exact subject filter");
31753
+ }
31754
+ const filterHash = relevanceRequested ? sessionDiscoveryFilterHash(filters) : null;
31755
+ const readPage = async (scopedDb: Database) => {
31756
+ const requestedUpdatedAfter = options.updatedAfter ?? options.cursor?.updatedAfter ?? null;
31757
+ const updatedAfter =
31758
+ requestedUpdatedAfter === null
31759
+ ? null
31760
+ : normalizeSessionActivityRevision(requestedUpdatedAfter, "updatedAfter");
31761
+ if (options.cursor?.orderBy !== undefined && options.cursor.orderBy !== orderBy) {
31762
+ throw new Error("sessions_list cursor order does not match the request");
31763
+ }
31764
+ if (options.cursor && options.cursor.updatedAfter !== updatedAfter) {
31765
+ throw new Error("sessions_list cursor incremental filter does not match the request");
31766
+ }
31767
+ if (updatedAfter !== null && orderBy !== "updatedAt") {
31768
+ throw new Error("sessions_list updatedAfter requires orderBy=updatedAt");
31769
+ }
31770
+ if (options.cursor && orderBy === "relevance") {
31018
31771
  if (
31019
- orderBy === "createdAt" &&
31020
- options.cursor &&
31021
- (cursorSortRevision !== "0" || options.cursor.snapshotRevision !== "0")
31772
+ !Number.isSafeInteger(options.cursor.sortRank) ||
31773
+ options.cursor.sortRank === null ||
31774
+ options.cursor.sortRank < 0
31022
31775
  ) {
31023
- throw new Error("sessions_list created-order cursor cannot carry activity revisions");
31024
- }
31025
- const cursorPredicate = options.cursor
31026
- ? orderBy === "updatedAt"
31027
- ? or(
31028
- sql`${schema.sessions.activityRevision} < ${cursorSortRevision!}::text::bigint`,
31029
- and(
31030
- sql`${schema.sessions.activityRevision} = ${cursorSortRevision!}::text::bigint`,
31031
- or(
31032
- sql`${schema.sessions.updatedAt} < ${options.cursor.sortAt}::text::timestamptz`,
31033
- and(
31034
- sql`${schema.sessions.updatedAt} = ${options.cursor.sortAt}::text::timestamptz`,
31035
- lt(schema.sessions.id, options.cursor.id),
31036
- ),
31037
- ),
31038
- ),
31039
- )
31040
- : or(
31041
- // Cast through text deliberately. postgres.js otherwise infers a
31042
- // timestamptz parameter and serializes this exact cursor string via
31043
- // JS Date, which discards PostgreSQL's sub-millisecond precision.
31044
- sql`${schema.sessions.createdAt} < ${options.cursor.sortAt}::text::timestamptz`,
31045
- and(
31046
- sql`${schema.sessions.createdAt} = ${options.cursor.sortAt}::text::timestamptz`,
31047
- lt(schema.sessions.id, options.cursor.id),
31048
- ),
31049
- )
31050
- : undefined;
31051
- const snapshotFilters: SQL[] = [eq(schema.sessions.workspaceId, workspaceId)];
31052
- if (options.subjectId) {
31053
- snapshotFilters.push(sql`not exists (
31054
- select 1
31055
- from ${schema.slackInteractions} private_slack_interaction
31056
- where private_slack_interaction.workspace_id = ${schema.sessions.workspaceId}
31057
- and private_slack_interaction.session_reservation_id = ${schema.sessions.rootSessionId}
31058
- and private_slack_interaction.visibility = 'private'
31059
- and private_slack_interaction.owning_subject_id <> ${options.subjectId}
31060
- )`);
31061
- }
31062
- if (options.authorizationScope) {
31063
- snapshotFilters.push(sessionAuthorizationScopeFilter(options.authorizationScope));
31064
- }
31065
- if (Object.prototype.hasOwnProperty.call(options, "parentSessionId")) {
31066
- snapshotFilters.push(
31067
- options.parentSessionId === null
31068
- ? isNull(schema.sessions.parentSessionId)
31069
- : eq(schema.sessions.parentSessionId, options.parentSessionId!),
31070
- );
31071
- }
31072
- const search = options.search?.trim();
31073
- if (search) {
31074
- const pattern = `%${search
31075
- .replaceAll("\\", "\\\\")
31076
- .replaceAll("%", "\\%")
31077
- .replaceAll("_", "\\_")}%`;
31078
- snapshotFilters.push(
31079
- or(
31080
- ilike(schema.sessions.title, pattern),
31081
- ilike(schema.sessions.initialMessage, pattern),
31082
- )!,
31083
- );
31776
+ throw new Error("sessions_list relevance cursor rank is invalid");
31084
31777
  }
31085
- snapshotFilters.push(
31086
- orderBy === "updatedAt"
31087
- ? sql`${schema.sessions.activityRevision} <= ${snapshotRevision}::text::bigint`
31088
- : sql`${schema.sessions.createdAt} <= ${snapshotAt}::text::timestamptz`,
31089
- );
31090
- if (updatedAfter !== null) {
31091
- snapshotFilters.push(
31092
- sql`${schema.sessions.activityRevision} > ${updatedAfter}::text::bigint`,
31093
- );
31778
+ if (options.cursor.filterHash !== filterHash) {
31779
+ throw new Error("sessions_list cursor relevance filters do not match the request");
31094
31780
  }
31095
- const rows = await scopedDb
31096
- .select({
31097
- id: schema.sessions.id,
31098
- title: sql<
31099
- string | null
31100
- >`left(${schema.sessions.title}, ${SESSION_DISCOVERY_CONTROL_TITLE_MAX_CHARS})`,
31101
- titleOriginalChars: sql<number | null>`char_length(${schema.sessions.title})::integer`,
31102
- parentSessionId: schema.sessions.parentSessionId,
31103
- rootSessionId: schema.sessions.rootSessionId,
31104
- nestedAgentDepth: schema.sessions.nestedAgentDepth,
31105
- status: schema.sessions.status,
31106
- createdAt: schema.sessions.createdAt,
31107
- updatedAt: schema.sessions.updatedAt,
31108
- sortRevision:
31109
- orderBy === "updatedAt"
31110
- ? sql<string>`${schema.sessions.activityRevision}::text`
31111
- : sql<string>`'0'`,
31112
- sortAt:
31113
- orderBy === "updatedAt"
31114
- ? sql<string>`to_char(${schema.sessions.updatedAt} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`
31115
- : sql<string>`to_char(${schema.sessions.createdAt} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`,
31116
- })
31117
- .from(schema.sessions)
31118
- .where(and(...snapshotFilters, cursorPredicate))
31119
- .orderBy(
31120
- ...(orderBy === "updatedAt"
31121
- ? [
31122
- desc(schema.sessions.activityRevision),
31123
- desc(schema.sessions.updatedAt),
31124
- desc(schema.sessions.id),
31125
- ]
31126
- : [desc(schema.sessions.createdAt), desc(schema.sessions.id)]),
31781
+ } else if (
31782
+ options.cursor &&
31783
+ (options.cursor.sortRank !== null || options.cursor.filterHash !== null)
31784
+ ) {
31785
+ throw new Error("sessions_list chronological cursor cannot carry relevance fields");
31786
+ }
31787
+ const snapshotAt =
31788
+ options.cursor?.snapshotAt ??
31789
+ (
31790
+ await rawRows<{ value: string }>(
31791
+ scopedDb,
31792
+ // The transaction-start cutoff is no later than any statement or
31793
+ // repeatable-read snapshot used below. Later rows therefore belong
31794
+ // to the next traversal instead of entering this cursor mid-scan.
31795
+ sql`select to_char(transaction_timestamp() at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') as value`,
31127
31796
  )
31128
- .limit(limit + 1);
31129
- const hasMore = rows.length > limit;
31130
- const page = rows.slice(0, limit);
31131
- const ids = page.map((row) => row.id);
31132
- const [{ total } = { total: 0 }] = await scopedDb
31133
- .select({ total: sql<number>`count(*)::int` })
31134
- .from(schema.sessions)
31135
- .where(and(...snapshotFilters));
31136
- if (ids.length === 0) {
31137
- return {
31138
- sessions: [],
31139
- hasMore: false,
31140
- nextCursor: null,
31141
- total: Number(total),
31142
- orderBy,
31143
- snapshotAt,
31144
- snapshotRevision,
31145
- updatedThrough: orderBy === "updatedAt" ? snapshotRevision : null,
31146
- updatedAfter,
31147
- };
31148
- }
31797
+ )[0]!.value;
31798
+ const snapshotRevision =
31799
+ orderBy !== "createdAt"
31800
+ ? options.cursor?.snapshotRevision !== undefined
31801
+ ? normalizeSessionActivityRevision(
31802
+ options.cursor.snapshotRevision,
31803
+ "cursor snapshot revision",
31804
+ )
31805
+ : await readWorkspaceSessionActivityRevision(scopedDb, workspaceId)
31806
+ : "0";
31807
+ const cursorSortRevision = options.cursor
31808
+ ? normalizeSessionActivityRevision(options.cursor.sortRevision, "cursor sort revision")
31809
+ : null;
31810
+ if (
31811
+ orderBy === "createdAt" &&
31812
+ options.cursor &&
31813
+ (cursorSortRevision !== "0" ||
31814
+ options.cursor.snapshotRevision !== "0" ||
31815
+ options.cursor.sortRank !== null ||
31816
+ options.cursor.filterHash !== null)
31817
+ ) {
31818
+ throw new Error("sessions_list created-order cursor cannot carry activity revisions");
31819
+ }
31820
+ const selected = await selectSessionDiscoveryPageRows(scopedDb, workspaceId, {
31821
+ limit,
31822
+ orderBy,
31823
+ ...(options.cursor ? { cursor: options.cursor } : {}),
31824
+ snapshotAt,
31825
+ snapshotRevision,
31826
+ updatedAfter,
31827
+ ...(options.subjectId ? { subjectId: options.subjectId } : {}),
31828
+ ...(options.authorizationScope ? { authorizationScope: options.authorizationScope } : {}),
31829
+ filters,
31830
+ });
31831
+ const hasMore = selected.rows.length > limit;
31832
+ const page = selected.rows.slice(0, limit);
31833
+ const ids = page.map((row) => row.id!);
31834
+ if (ids.length === 0) {
31835
+ return {
31836
+ sessions: [],
31837
+ hasMore: false,
31838
+ nextCursor: null,
31839
+ total: selected.total,
31840
+ orderBy,
31841
+ snapshotAt,
31842
+ snapshotRevision,
31843
+ updatedThrough: orderBy === "updatedAt" ? snapshotRevision : null,
31844
+ updatedAfter,
31845
+ filterHash,
31846
+ };
31847
+ }
31149
31848
 
31150
- const rootRelatedIds = await sessionIdsCoveredByAuthorizationRoots(
31151
- scopedDb,
31152
- workspaceId,
31153
- ids,
31154
- options.authorizationScope,
31155
- );
31156
- const controls = await evaluateSessionDiscoveryControls(scopedDb, workspaceId, ids);
31157
- const treeStats = await sessionTreeStatsForSessions(scopedDb, workspaceId, [
31158
- ...rootRelatedIds,
31159
- ]);
31160
- const goals = await scopedDb
31161
- .select({
31162
- sessionId: schema.sessionGoals.sessionId,
31163
- status: schema.sessionGoals.status,
31164
- text: sql<string>`left(${schema.sessionGoals.text}, ${SESSION_DISCOVERY_GOAL_MAX_CHARS})`,
31165
- textOriginalChars: sql<number>`char_length(${schema.sessionGoals.text})::integer`,
31166
- })
31167
- .from(schema.sessionGoals)
31168
- .where(
31169
- and(
31170
- eq(schema.sessionGoals.workspaceId, workspaceId),
31171
- inArray(schema.sessionGoals.sessionId, ids),
31172
- ),
31173
- );
31174
- const goalsBySession = new Map(goals.map((goal) => [goal.sessionId, goal]));
31175
- const queueCounts = await scopedDb
31176
- .select({
31177
- sessionId: schema.sessionTurns.sessionId,
31178
- count: sql<number>`count(*)::int`,
31849
+ const rootRelatedIds = await sessionIdsCoveredByAuthorizationRoots(
31850
+ scopedDb,
31851
+ workspaceId,
31852
+ ids,
31853
+ options.authorizationScope,
31854
+ );
31855
+ const controls = await evaluateSessionDiscoveryControls(scopedDb, workspaceId, ids);
31856
+ const treeStats = await sessionTreeStatsForSessions(scopedDb, workspaceId, [...rootRelatedIds]);
31857
+ const includeWorkDiscovery = options.includeWorkDiscovery !== false;
31858
+ const workClaims = includeWorkDiscovery
31859
+ ? await sessionDiscoveryClaimsForSessions(scopedDb, workspaceId, ids, {
31860
+ snapshotAt,
31861
+ activeOnly: filters.activeOnly,
31862
+ recentHours: filters.recentHours,
31863
+ limit: claimLimit,
31864
+ matchedClaimIds: new Map(page.map((row) => [row.id!, row.matchedClaimId] as const)),
31179
31865
  })
31180
- .from(schema.sessionTurns)
31181
- .where(
31182
- and(
31183
- eq(schema.sessionTurns.workspaceId, workspaceId),
31184
- inArray(schema.sessionTurns.sessionId, ids),
31185
- eq(schema.sessionTurns.status, "queued"),
31186
- inArray(schema.sessionTurns.source, ["user", "api"]),
31187
- ),
31188
- )
31189
- .groupBy(schema.sessionTurns.sessionId);
31190
- const queueBySession = new Map(
31191
- queueCounts.map((entry) => [entry.sessionId, Number(entry.count)]),
31866
+ : new Map<string, { claims: WorkClaimDiscoverySummary[]; truncated: boolean }>();
31867
+ const goals = await scopedDb
31868
+ .select({
31869
+ sessionId: schema.sessionGoals.sessionId,
31870
+ status: schema.sessionGoals.status,
31871
+ text: sql<string>`left(${schema.sessionGoals.text}, ${SESSION_DISCOVERY_GOAL_MAX_CHARS})`,
31872
+ textOriginalChars: sql<number>`char_length(${schema.sessionGoals.text})::integer`,
31873
+ })
31874
+ .from(schema.sessionGoals)
31875
+ .where(
31876
+ and(
31877
+ eq(schema.sessionGoals.workspaceId, workspaceId),
31878
+ inArray(schema.sessionGoals.sessionId, ids),
31879
+ ),
31192
31880
  );
31193
- const latestMessages = options.includeLastMessage
31194
- ? await scopedDb
31195
- .selectDistinctOn([schema.sessionEvents.sessionId], {
31196
- sessionId: schema.sessionEvents.sessionId,
31197
- type: schema.sessionEvents.type,
31198
- // Extract only the bounded textual preview in PostgreSQL. Selecting
31199
- // the JSON payload here would re-materialize the exact multi-MB
31200
- // event bodies this compact discovery path exists to avoid.
31201
- preview: sql<string | null>`left(coalesce(
31881
+ const goalsBySession = new Map(goals.map((goal) => [goal.sessionId, goal]));
31882
+ const queueCounts = await scopedDb
31883
+ .select({
31884
+ sessionId: schema.sessionTurns.sessionId,
31885
+ count: sql<number>`count(*)::int`,
31886
+ })
31887
+ .from(schema.sessionTurns)
31888
+ .where(
31889
+ and(
31890
+ eq(schema.sessionTurns.workspaceId, workspaceId),
31891
+ inArray(schema.sessionTurns.sessionId, ids),
31892
+ eq(schema.sessionTurns.status, "queued"),
31893
+ inArray(schema.sessionTurns.source, ["user", "api"]),
31894
+ ),
31895
+ )
31896
+ .groupBy(schema.sessionTurns.sessionId);
31897
+ const queueBySession = new Map(
31898
+ queueCounts.map((entry) => [entry.sessionId, Number(entry.count)]),
31899
+ );
31900
+ const latestMessages = options.includeLastMessage
31901
+ ? await scopedDb
31902
+ .selectDistinctOn([schema.sessionEvents.sessionId], {
31903
+ sessionId: schema.sessionEvents.sessionId,
31904
+ type: schema.sessionEvents.type,
31905
+ // Extract only the bounded textual preview in PostgreSQL. Selecting
31906
+ // the JSON payload here would re-materialize the exact multi-MB
31907
+ // event bodies this compact discovery path exists to avoid.
31908
+ preview: sql<string | null>`left(coalesce(
31202
31909
  ${schema.sessionEvents.payload}->>'text',
31203
31910
  ${schema.sessionEvents.payload}->>'message',
31204
31911
  ${schema.sessionEvents.payload}->>'content'
31205
31912
  ), ${SESSION_DISCOVERY_MESSAGE_MAX_CHARS})`,
31206
- previewOriginalChars: sql<number | null>`char_length(coalesce(
31913
+ previewOriginalChars: sql<number | null>`char_length(coalesce(
31207
31914
  ${schema.sessionEvents.payload}->>'text',
31208
31915
  ${schema.sessionEvents.payload}->>'message',
31209
31916
  ${schema.sessionEvents.payload}->>'content'
31210
31917
  ))::integer`,
31211
- })
31212
- .from(schema.sessionEvents)
31213
- .where(
31214
- and(
31215
- eq(schema.sessionEvents.workspaceId, workspaceId),
31216
- inArray(schema.sessionEvents.sessionId, ids),
31217
- inArray(schema.sessionEvents.type, ["user.message", "agent.message.completed"]),
31218
- excludeUnclaimedHumanPromptEventFilter(workspaceId),
31219
- ),
31220
- )
31221
- .orderBy(schema.sessionEvents.sessionId, desc(schema.sessionEvents.sequence))
31222
- : [];
31223
- const latestBySession = new Map(latestMessages.map((entry) => [entry.sessionId, entry]));
31224
- const sessions = page.map((row): SessionDiscoverySummary => {
31225
- const control = controls.get(row.id);
31226
- if (!control) throw new Error(`Effective control missing for session ${row.id}`);
31227
- const relatedAccess = rootRelatedIds.has(row.id) ? "root" : "target";
31228
- const goal = goalsBySession.get(row.id);
31229
- const latest = latestBySession.get(row.id);
31230
- return {
31231
- id: row.id,
31232
- title: row.title,
31233
- titleOriginalChars:
31234
- row.titleOriginalChars === null ? null : Number(row.titleOriginalChars),
31235
- parentSessionId: relatedAccess === "root" ? row.parentSessionId : null,
31236
- rootSessionId: relatedAccess === "root" ? row.rootSessionId : row.id,
31237
- nestedAgentDepth: row.nestedAgentDepth,
31238
- status: row.status as SessionStatus,
31239
- effectiveControl: projectSessionDiscoveryControlForRelatedAccess(
31240
- control,
31241
- row.id,
31242
- relatedAccess,
31243
- ),
31244
- goal: goal
31245
- ? {
31246
- status: goal.status as SessionGoalStatus,
31247
- text: goal.text,
31248
- textOriginalChars: Number(goal.textOriginalChars),
31249
- }
31250
- : null,
31251
- queuedPromptCount: queueBySession.get(row.id) ?? 0,
31252
- treeStats:
31253
- relatedAccess === "root"
31254
- ? (treeStats.get(row.id) ?? EMPTY_SESSION_TREE_STATS)
31255
- : EMPTY_SESSION_TREE_STATS,
31256
- latestMessage: latest
31257
- ? {
31258
- type: latest.type as SessionEventType,
31259
- preview: latest.preview,
31260
- previewOriginalChars:
31261
- latest.previewOriginalChars === null ? null : Number(latest.previewOriginalChars),
31262
- }
31263
- : null,
31264
- createdAt: row.createdAt.toISOString(),
31265
- updatedAt: row.updatedAt.toISOString(),
31266
- sortRevision: row.sortRevision,
31267
- sortAt: row.sortAt,
31268
- };
31269
- });
31270
- const last = page.at(-1);
31918
+ })
31919
+ .from(schema.sessionEvents)
31920
+ .where(
31921
+ and(
31922
+ eq(schema.sessionEvents.workspaceId, workspaceId),
31923
+ inArray(schema.sessionEvents.sessionId, ids),
31924
+ inArray(schema.sessionEvents.type, ["user.message", "agent.message.completed"]),
31925
+ excludeUnclaimedHumanPromptEventFilter(workspaceId),
31926
+ ),
31927
+ )
31928
+ .orderBy(schema.sessionEvents.sessionId, desc(schema.sessionEvents.sequence))
31929
+ : [];
31930
+ const latestBySession = new Map(latestMessages.map((entry) => [entry.sessionId, entry]));
31931
+ const sessions = page.map((row): SessionDiscoverySummary => {
31932
+ const sessionId = row.id!;
31933
+ const control = controls.get(sessionId);
31934
+ if (!control) throw new Error(`Effective control missing for session ${sessionId}`);
31935
+ const relatedAccess = rootRelatedIds.has(sessionId) ? "root" : "target";
31936
+ const goal = goalsBySession.get(sessionId);
31937
+ const latest = latestBySession.get(sessionId);
31938
+ const claims = workClaims.get(sessionId) ?? { claims: [], truncated: false };
31939
+ const match =
31940
+ !includeWorkDiscovery ||
31941
+ row.matchClass === null ||
31942
+ row.matchedField === null ||
31943
+ row.scoreBand === null
31944
+ ? null
31945
+ : WorkDiscoveryMatchSchema.parse({
31946
+ class: row.matchClass,
31947
+ field: row.matchedField,
31948
+ scoreBand: row.scoreBand,
31949
+ claimId: row.matchedClaimId,
31950
+ });
31271
31951
  return {
31272
- sessions,
31273
- hasMore,
31274
- nextCursor:
31275
- hasMore && last
31276
- ? {
31277
- orderBy,
31278
- sortRevision: last.sortRevision,
31279
- sortAt: last.sortAt,
31280
- id: last.id,
31281
- snapshotAt,
31282
- snapshotRevision,
31283
- updatedAfter,
31284
- }
31285
- : null,
31286
- total: Number(total),
31287
- orderBy,
31288
- snapshotAt,
31289
- snapshotRevision,
31290
- updatedThrough: orderBy === "updatedAt" ? snapshotRevision : null,
31291
- updatedAfter,
31952
+ id: sessionId,
31953
+ title: row.title,
31954
+ titleOriginalChars: row.titleOriginalChars === null ? null : Number(row.titleOriginalChars),
31955
+ parentSessionId: relatedAccess === "root" ? row.parentSessionId : null,
31956
+ rootSessionId: relatedAccess === "root" ? row.rootSessionId! : sessionId,
31957
+ nestedAgentDepth: Number(row.nestedAgentDepth),
31958
+ status: row.status as SessionStatus,
31959
+ effectiveControl: projectSessionDiscoveryControlForRelatedAccess(
31960
+ control,
31961
+ sessionId,
31962
+ relatedAccess,
31963
+ ),
31964
+ goal: goal
31965
+ ? {
31966
+ status: goal.status as SessionGoalStatus,
31967
+ text: goal.text,
31968
+ textOriginalChars: Number(goal.textOriginalChars),
31969
+ }
31970
+ : null,
31971
+ queuedPromptCount: queueBySession.get(sessionId) ?? 0,
31972
+ treeStats:
31973
+ relatedAccess === "root"
31974
+ ? (treeStats.get(sessionId) ?? EMPTY_SESSION_TREE_STATS)
31975
+ : EMPTY_SESSION_TREE_STATS,
31976
+ latestMessage: latest
31977
+ ? {
31978
+ type: latest.type as SessionEventType,
31979
+ preview: latest.preview,
31980
+ previewOriginalChars:
31981
+ latest.previewOriginalChars === null ? null : Number(latest.previewOriginalChars),
31982
+ }
31983
+ : null,
31984
+ workDiscovery: {
31985
+ claims: claims.claims,
31986
+ claimsTruncated: claims.truncated,
31987
+ match,
31988
+ possibleOverlap: match !== null,
31989
+ advisoryOnly: true,
31990
+ noAdditionalAccess: true,
31991
+ },
31992
+ createdAt: sessionDiscoveryIso(row.createdAt!),
31993
+ updatedAt: sessionDiscoveryIso(row.updatedAt!),
31994
+ sortRevision: row.sortRevision!,
31995
+ sortAt: row.sortAt!,
31996
+ sortRank: row.sortRank === null ? null : Number(row.sortRank),
31292
31997
  };
31293
- },
31294
- orderBy === "updatedAt"
31295
- ? { isolationLevel: "repeatable read", accessMode: "read only" }
31296
- : { isolationLevel: "read committed", accessMode: "read only" },
31297
- );
31998
+ });
31999
+ const last = page.at(-1);
32000
+ return {
32001
+ sessions,
32002
+ hasMore,
32003
+ nextCursor:
32004
+ hasMore && last
32005
+ ? {
32006
+ orderBy,
32007
+ sortRank: last.sortRank === null ? null : Number(last.sortRank),
32008
+ sortRevision: last.sortRevision!,
32009
+ sortAt: last.sortAt!,
32010
+ id: last.id!,
32011
+ snapshotAt,
32012
+ snapshotRevision,
32013
+ updatedAfter,
32014
+ filterHash,
32015
+ }
32016
+ : null,
32017
+ total: selected.total,
32018
+ orderBy,
32019
+ snapshotAt,
32020
+ snapshotRevision,
32021
+ updatedThrough: orderBy === "updatedAt" ? snapshotRevision : null,
32022
+ updatedAfter,
32023
+ filterHash,
32024
+ };
32025
+ };
32026
+ const transactionConfig: PgTransactionConfig =
32027
+ orderBy === "createdAt"
32028
+ ? { isolationLevel: "read committed", accessMode: "read only" }
32029
+ : { isolationLevel: "repeatable read", accessMode: "read only" };
32030
+ return options.subjectId
32031
+ ? await withWorkspaceSubjectRls(db, workspaceId, options.subjectId, readPage, transactionConfig)
32032
+ : await withWorkspaceRls(db, workspaceId, readPage, transactionConfig);
31298
32033
  }
31299
32034
 
31300
32035
  export type SessionDiscoveryAncestor = {
@@ -31313,10 +32048,34 @@ export async function listSessionDiscoveryAncestorPaths(
31313
32048
  workspaceId: string,
31314
32049
  sessionIds: string[],
31315
32050
  authorizationScope?: SessionAuthorizationListScope,
32051
+ subjectId?: string,
31316
32052
  ): Promise<Map<string, SessionDiscoveryAncestor[]>> {
31317
32053
  const targetIds = [...new Set(sessionIds)].slice(0, 100);
31318
32054
  if (targetIds.length === 0) return new Map();
31319
- return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
32055
+ const readPaths = async (scopedDb: Database) => {
32056
+ const authorizationFilters: SQL[] = [eq(schema.sessions.workspaceId, workspaceId)];
32057
+ authorizationFilters.push(
32058
+ subjectId
32059
+ ? sql`not exists (
32060
+ select 1
32061
+ from ${schema.slackInteractions} private_slack_interaction
32062
+ where private_slack_interaction.workspace_id = ${schema.sessions.workspaceId}
32063
+ and private_slack_interaction.session_reservation_id = ${schema.sessions.rootSessionId}
32064
+ and private_slack_interaction.visibility = 'private'
32065
+ and private_slack_interaction.owning_subject_id <> ${subjectId}
32066
+ )`
32067
+ : sql`not exists (
32068
+ select 1
32069
+ from ${schema.slackInteractions} private_slack_interaction
32070
+ where private_slack_interaction.workspace_id = ${schema.sessions.workspaceId}
32071
+ and private_slack_interaction.session_reservation_id = ${schema.sessions.rootSessionId}
32072
+ and private_slack_interaction.visibility = 'private'
32073
+ )`,
32074
+ );
32075
+ if (authorizationScope) {
32076
+ authorizationFilters.push(sessionAuthorizationScopeFilter(authorizationScope));
32077
+ }
32078
+ const authorizedPredicate = and(...authorizationFilters) ?? sql`false`;
31320
32079
  const rows = await rawRows<{
31321
32080
  targetId: string;
31322
32081
  id: string;
@@ -31327,7 +32086,15 @@ export async function listSessionDiscoveryAncestorPaths(
31327
32086
  }>(
31328
32087
  scopedDb,
31329
32088
  sql`
31330
- with recursive lineage as (
32089
+ with recursive authorized_sessions as materialized (
32090
+ select
32091
+ ${schema.sessions.id} as id,
32092
+ ${schema.sessions.parentSessionId} as parent_session_id,
32093
+ ${schema.sessions.title} as title
32094
+ from ${schema.sessions}
32095
+ where ${authorizedPredicate}
32096
+ ),
32097
+ lineage as (
31331
32098
  select
31332
32099
  target.id as target_id,
31333
32100
  target.id,
@@ -31336,9 +32103,8 @@ export async function listSessionDiscoveryAncestorPaths(
31336
32103
  0::integer as depth,
31337
32104
  array[target.id]::uuid[] as path,
31338
32105
  false as cycle
31339
- from ${schema.sessions} target
31340
- where target.workspace_id = ${workspaceId}
31341
- and ${inArray(sql`target.id`, targetIds)}
32106
+ from authorized_sessions target
32107
+ where ${inArray(sql`target.id`, targetIds)}
31342
32108
 
31343
32109
  union all
31344
32110
 
@@ -31351,9 +32117,7 @@ export async function listSessionDiscoveryAncestorPaths(
31351
32117
  child.path || parent.id,
31352
32118
  parent.id = any(child.path)
31353
32119
  from lineage child
31354
- join ${schema.sessions} parent
31355
- on parent.workspace_id = ${workspaceId}
31356
- and parent.id = child.parent_session_id
32120
+ join authorized_sessions parent on parent.id = child.parent_session_id
31357
32121
  where not child.cycle
31358
32122
  and child.depth < 64
31359
32123
  )
@@ -31369,24 +32133,9 @@ export async function listSessionDiscoveryAncestorPaths(
31369
32133
  order by target_id, depth desc
31370
32134
  `,
31371
32135
  );
31372
- const candidateIds = [...new Set(rows.filter((row) => !row.cycle).map((row) => row.id))];
31373
- let allowed = new Set(candidateIds);
31374
- if (authorizationScope && authorizationScope.kind !== "all" && candidateIds.length > 0) {
31375
- const allowedRows = await scopedDb
31376
- .select({ id: schema.sessions.id })
31377
- .from(schema.sessions)
31378
- .where(
31379
- and(
31380
- eq(schema.sessions.workspaceId, workspaceId),
31381
- inArray(schema.sessions.id, candidateIds),
31382
- sessionAuthorizationScopeFilter(authorizationScope),
31383
- ),
31384
- );
31385
- allowed = new Set(allowedRows.map((row) => row.id));
31386
- }
31387
32136
  const result = new Map<string, SessionDiscoveryAncestor[]>();
31388
32137
  for (const row of rows) {
31389
- if (row.cycle || !allowed.has(row.id)) continue;
32138
+ if (row.cycle) continue;
31390
32139
  const path = result.get(row.targetId) ?? [];
31391
32140
  path.push({
31392
32141
  id: row.id,
@@ -31396,7 +32145,10 @@ export async function listSessionDiscoveryAncestorPaths(
31396
32145
  result.set(row.targetId, path);
31397
32146
  }
31398
32147
  return result;
31399
- });
32148
+ };
32149
+ return subjectId
32150
+ ? await withWorkspaceSubjectRls(db, workspaceId, subjectId, readPaths)
32151
+ : await withWorkspaceRls(db, workspaceId, readPaths);
31400
32152
  }
31401
32153
 
31402
32154
  export type SessionLineage = {
@@ -32589,6 +33341,45 @@ export async function listSessionHumanInputRequests(
32589
33341
  ): Promise<SessionHumanInputRequest[]> {
32590
33342
  const limit = Math.min(100, Math.max(1, Math.floor(options.limit ?? 50)));
32591
33343
  return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
33344
+ const actionablePendingIds =
33345
+ options.status === "pending"
33346
+ ? await scopedDb.execute<{ id: string }>(sql`
33347
+ select request_row.id
33348
+ from session_human_input_requests request_row
33349
+ join sessions session_row
33350
+ on session_row.workspace_id = request_row.workspace_id
33351
+ and session_row.id = request_row.session_id
33352
+ join session_turns turn_row
33353
+ on turn_row.workspace_id = request_row.workspace_id
33354
+ and turn_row.session_id = request_row.session_id
33355
+ and turn_row.id = request_row.turn_id
33356
+ join session_events trigger_row
33357
+ on trigger_row.workspace_id = turn_row.workspace_id
33358
+ and trigger_row.session_id = turn_row.session_id
33359
+ and trigger_row.id = turn_row.trigger_event_id
33360
+ where request_row.workspace_id = ${workspaceId}
33361
+ and request_row.session_id = ${sessionId}
33362
+ and request_row.status = 'pending'
33363
+ and (request_row.expires_at is null or request_row.expires_at > now())
33364
+ and session_row.status = 'requires_action'
33365
+ and session_row.active_turn_id = request_row.turn_id
33366
+ and turn_row.status = 'requires_action'
33367
+ and turn_row.execution_generation = request_row.turn_generation
33368
+ and not exists (
33369
+ select 1
33370
+ from session_events accepted_row
33371
+ where accepted_row.workspace_id = request_row.workspace_id
33372
+ and accepted_row.session_id = request_row.session_id
33373
+ and accepted_row.turn_id = request_row.turn_id
33374
+ and accepted_row.turn_generation = request_row.turn_generation
33375
+ and accepted_row.type in ('user.approvalDecision', 'user.humanInputResponse')
33376
+ and accepted_row.sequence > trigger_row.sequence
33377
+ )
33378
+ order by request_row.created_at desc, request_row.id desc
33379
+ limit ${limit}
33380
+ `)
33381
+ : null;
33382
+ if (actionablePendingIds !== null && actionablePendingIds.length === 0) return [];
32592
33383
  const predicates = [
32593
33384
  eq(schema.sessionHumanInputRequests.workspaceId, workspaceId),
32594
33385
  eq(schema.sessionHumanInputRequests.sessionId, sessionId),
@@ -32596,6 +33387,14 @@ export async function listSessionHumanInputRequests(
32596
33387
  if (options.status) {
32597
33388
  predicates.push(eq(schema.sessionHumanInputRequests.status, options.status));
32598
33389
  }
33390
+ if (actionablePendingIds !== null) {
33391
+ predicates.push(
33392
+ inArray(
33393
+ schema.sessionHumanInputRequests.id,
33394
+ actionablePendingIds.map((row: { id: string }) => row.id),
33395
+ ),
33396
+ );
33397
+ }
32599
33398
  const rows = await scopedDb
32600
33399
  .select()
32601
33400
  .from(schema.sessionHumanInputRequests)
@@ -32721,6 +33520,13 @@ export type AcceptSessionHumanInputResponseResult =
32721
33520
  events: SessionEvent[];
32722
33521
  workflowWakeRevision: number;
32723
33522
  }
33523
+ | {
33524
+ action: "completed";
33525
+ request: SessionHumanInputRequest;
33526
+ event: SessionEvent;
33527
+ events: SessionEvent[];
33528
+ workflowWakeRevision: number | null;
33529
+ }
32724
33530
  | {
32725
33531
  action: "conflict";
32726
33532
  request: SessionHumanInputRequest;
@@ -32774,10 +33580,37 @@ async function hasAcceptedRequiresActionAdvance(
32774
33580
  return accepted !== undefined;
32775
33581
  }
32776
33582
 
33583
+ async function humanInputResponseEventForRequest(
33584
+ tx: Database,
33585
+ input: {
33586
+ workspaceId: string;
33587
+ sessionId: string;
33588
+ requestId: string;
33589
+ },
33590
+ ): Promise<SessionEvent | null> {
33591
+ const [event] = await tx
33592
+ .select()
33593
+ .from(schema.sessionEvents)
33594
+ .where(
33595
+ and(
33596
+ eq(schema.sessionEvents.workspaceId, input.workspaceId),
33597
+ eq(schema.sessionEvents.sessionId, input.sessionId),
33598
+ eq(schema.sessionEvents.type, "user.humanInputResponse"),
33599
+ sql`${schema.sessionEvents.payload} ->> 'requestId' = ${input.requestId}`,
33600
+ ),
33601
+ )
33602
+ .orderBy(desc(schema.sessionEvents.sequence))
33603
+ .limit(1);
33604
+ return event ? mapEvent(event) : null;
33605
+ }
33606
+
32777
33607
  /**
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.
33608
+ * First-writer-wins settlement for one structured input boundary. Session +
33609
+ * logical turn + current freeze generation are locked with the row; the
33610
+ * creation attempt is provenance and is intentionally not the resume owner.
33611
+ * A committed response replays its original event without another event or
33612
+ * workflow wake. Historical cancelled rows missing that canonical event are
33613
+ * repaired exactly once under the same locks, but never wake terminal work.
32781
33614
  */
32782
33615
  export async function acceptSessionHumanInputResponse(
32783
33616
  db: Database,
@@ -32845,25 +33678,105 @@ export async function acceptSessionHumanInputResponse(
32845
33678
  ) {
32846
33679
  throw new Error("clientEventId belongs to a different session operation");
32847
33680
  }
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
33681
  return {
32859
- action: "accepted",
33682
+ action: "completed",
32860
33683
  request: mapSessionHumanInputRequest(request),
32861
33684
  event: mapEvent(existing),
32862
33685
  events: [],
32863
- workflowWakeRevision,
33686
+ workflowWakeRevision: null,
32864
33687
  } as const;
32865
33688
  }
32866
33689
  }
33690
+ if (request.status !== "pending") {
33691
+ let event = await humanInputResponseEventForRequest(tx as unknown as Database, {
33692
+ workspaceId: input.workspaceId,
33693
+ sessionId: input.sessionId,
33694
+ requestId: request.id,
33695
+ });
33696
+ let repairedEvents: SessionEvent[] = [];
33697
+ if (
33698
+ !event &&
33699
+ request.status === "cancelled" &&
33700
+ (request.response as { outcome?: unknown } | null)?.outcome === "cancelled"
33701
+ ) {
33702
+ const turnLocks = await lockSessionEventWriteRows(tx as unknown as Database, {
33703
+ workspaceId: input.workspaceId,
33704
+ controlLock: "already_locked",
33705
+ workspaceLock: "already_locked",
33706
+ turnIds: [request.turnId],
33707
+ });
33708
+ const turn = turnLocks.turns[0];
33709
+ if (!turn || turn.sessionId !== session.id) {
33710
+ throw new SessionControlInvariantError(
33711
+ `Cancelled human-input request ${request.id} has no owning turn`,
33712
+ );
33713
+ }
33714
+ const occurredAt = request.respondedAt ?? request.updatedAt;
33715
+ const [inserted] = await tx
33716
+ .insert(schema.sessionEvents)
33717
+ .values(
33718
+ withLosslessContentWriteVersion(
33719
+ {
33720
+ accountId: session.accountId,
33721
+ workspaceId: input.workspaceId,
33722
+ sessionId: session.id,
33723
+ turnId: request.turnId,
33724
+ turnGeneration: request.turnGeneration,
33725
+ turnAssociation: "current",
33726
+ sequence: session.lastSequence + 1,
33727
+ type: "user.humanInputResponse",
33728
+ payload: { requestId: request.id, response: { outcome: "cancelled" } },
33729
+ clientEventId: null,
33730
+ occurredAt,
33731
+ },
33732
+ "payload",
33733
+ "payloadCodecVersion",
33734
+ ),
33735
+ )
33736
+ .returning();
33737
+ if (!inserted) {
33738
+ throw new SessionControlInvariantError(
33739
+ `Cancelled human-input request ${request.id} could not repair its response event`,
33740
+ );
33741
+ }
33742
+ await mirrorSessionRealtimeContextInTransaction(tx as unknown as Database, {
33743
+ accountId: session.accountId,
33744
+ workspaceId: input.workspaceId,
33745
+ sessionId: session.id,
33746
+ sourceKind: "human_input_response",
33747
+ sourceId: inserted.id,
33748
+ turnId: request.turnId,
33749
+ channel: null,
33750
+ text: renderRealtimeHumanInputResponseContext({
33751
+ requestId: request.id,
33752
+ questions: request.questions,
33753
+ response: { outcome: "cancelled" },
33754
+ }),
33755
+ payload: {
33756
+ requestId: request.id,
33757
+ outcome: "cancelled",
33758
+ sourceEventId: inserted.id,
33759
+ },
33760
+ now: occurredAt,
33761
+ });
33762
+ await tx
33763
+ .update(schema.sessions)
33764
+ .set({ lastSequence: session.lastSequence + 1, updatedAt: new Date() })
33765
+ .where(eq(schema.sessions.id, session.id));
33766
+ event = mapEvent(inserted);
33767
+ repairedEvents = [event];
33768
+ }
33769
+ if (!event) {
33770
+ throw new Error(`Terminal human-input request has no response event: ${request.id}`);
33771
+ }
33772
+ return {
33773
+ action: "completed",
33774
+ request: mapSessionHumanInputRequest(request),
33775
+ event,
33776
+ events: repairedEvents,
33777
+ workflowWakeRevision: null,
33778
+ } as const;
33779
+ }
32867
33780
  const [turnPreview] = session.activeTurnId
32868
33781
  ? await tx
32869
33782
  .select()
@@ -32892,7 +33805,7 @@ export async function acceptSessionHumanInputResponse(
32892
33805
  turn?.status === "requires_action" &&
32893
33806
  turn.id === request.turnId &&
32894
33807
  turn.executionGeneration === request.turnGeneration;
32895
- if (request.status !== "pending" || !boundaryOwnsRequest) {
33808
+ if (!boundaryOwnsRequest) {
32896
33809
  return {
32897
33810
  action: "conflict",
32898
33811
  request: mapSessionHumanInputRequest(request),
@@ -33033,9 +33946,7 @@ export async function acceptSessionHumanInputResponse(
33033
33946
  events: [mappedEvent],
33034
33947
  workflowWakeRevision,
33035
33948
  };
33036
- return expired
33037
- ? ({ action: "conflict", ...result } as const)
33038
- : ({ action: "accepted", event: mappedEvent, ...result } as const);
33949
+ return { action: "accepted", event: mappedEvent, ...result } as const;
33039
33950
  }),
33040
33951
  );
33041
33952
  }
@@ -42096,6 +43007,65 @@ async function lockWorkspaceMutationSessionTx(
42096
43007
  return session;
42097
43008
  }
42098
43009
 
43010
+ /** True while an exact closed attempt still owns the physical-quiescence gate.
43011
+ * Both user control interruptions and activity-owned recoverable shutdowns use
43012
+ * this predicate so wake delivery cannot turn an unfinished writer drain into
43013
+ * an acknowledged edge-trigger. */
43014
+ async function hasPendingSessionAttemptQuiescenceTx(
43015
+ tx: Database,
43016
+ input: { workspaceId: string; sessionId: string; attemptId?: string },
43017
+ ): Promise<boolean> {
43018
+ const [row] = await tx.execute<{ pending: boolean }>(sql`
43019
+ select exists (
43020
+ select 1
43021
+ from session_turn_attempts attempt
43022
+ where attempt.workspace_id = ${input.workspaceId}
43023
+ and attempt.session_id = ${input.sessionId}
43024
+ ${input.attemptId ? sql`and attempt.id = ${input.attemptId}` : sql``}
43025
+ and attempt.state = 'closed'
43026
+ and attempt.quiesced_at is null
43027
+ and (
43028
+ exists (
43029
+ select 1
43030
+ from session_attempt_interruptions interruption
43031
+ where interruption.workspace_id = attempt.workspace_id
43032
+ and interruption.session_id = attempt.session_id
43033
+ and interruption.attempt_id = attempt.id
43034
+ and interruption.state in ('settled', 'rejected_stale')
43035
+ )
43036
+ or (
43037
+ attempt.outcome = 'interrupted_recoverable'
43038
+ and not exists (
43039
+ select 1
43040
+ from session_turn_attempts successor
43041
+ where successor.account_id = attempt.account_id
43042
+ and successor.workspace_id = attempt.workspace_id
43043
+ and successor.session_id = attempt.session_id
43044
+ and (
43045
+ successor.started_at > attempt.started_at
43046
+ or (
43047
+ successor.started_at = attempt.started_at
43048
+ and successor.id > attempt.id
43049
+ )
43050
+ )
43051
+ )
43052
+ and exists (
43053
+ select 1
43054
+ from session_events event
43055
+ where event.account_id = attempt.account_id
43056
+ and event.workspace_id = attempt.workspace_id
43057
+ and event.session_id = attempt.session_id
43058
+ and event.turn_id = attempt.turn_id
43059
+ and event.turn_attempt_id = attempt.id
43060
+ and event.type = 'turn.recovery.requested'
43061
+ )
43062
+ )
43063
+ )
43064
+ ) as pending
43065
+ `);
43066
+ return row?.pending === true;
43067
+ }
43068
+
42099
43069
  /**
42100
43070
  * Resolve the organization grant identity behind a causal human, and refuse the
42101
43071
  * operation when that grant has been revoked or suspended.
@@ -44073,7 +45043,11 @@ export async function settleRetainedProcess(
44073
45043
  async (scopedDb) =>
44074
45044
  await scopedDb.transaction(async (txRaw) => {
44075
45045
  const tx = txRaw as unknown as Database;
44076
- await lockWorkspaceMutationSessionTx(tx, input.workspaceId, input.sessionId);
45046
+ const session = await lockWorkspaceMutationSessionTx(
45047
+ tx,
45048
+ input.workspaceId,
45049
+ input.sessionId,
45050
+ );
44077
45051
  const [process] = await tx
44078
45052
  .select()
44079
45053
  .from(schema.sandboxRetainedProcesses)
@@ -44280,6 +45254,22 @@ export async function settleRetainedProcess(
44280
45254
  where id = ${process.leaseId}
44281
45255
  and sandbox_group_id = ${process.sandboxGroupId}
44282
45256
  `);
45257
+ if (
45258
+ process.ownerAttemptId &&
45259
+ (await hasPendingSessionAttemptQuiescenceTx(tx, {
45260
+ workspaceId: input.workspaceId,
45261
+ sessionId: input.sessionId,
45262
+ attemptId: process.ownerAttemptId,
45263
+ }))
45264
+ ) {
45265
+ await enqueueSessionWorkflowWakeInTransaction(tx, {
45266
+ accountId: input.accountId,
45267
+ workspaceId: input.workspaceId,
45268
+ sessionId: input.sessionId,
45269
+ temporalWorkflowId: session.temporalWorkflowId ?? `session-${input.sessionId}`,
45270
+ reason: "retained_process_settled_quiescence",
45271
+ });
45272
+ }
44283
45273
  return { settled: true, process: mapRetainedProcess(updated) };
44284
45274
  }),
44285
45275
  );
@@ -59847,7 +60837,12 @@ export async function failSessionWorkBeforeAttemptClaim(
59847
60837
  workspaceId,
59848
60838
  {
59849
60839
  stage: "session_lifecycle_outbox.fail_before_attempt_claim",
59850
- eventTypes: ["child_terminal_result", "session.status.changed", "turn.failed"],
60840
+ eventTypes: [
60841
+ "child_terminal_result",
60842
+ "session.status.changed",
60843
+ "turn.failed",
60844
+ "user.humanInputResponse",
60845
+ ],
59851
60846
  maxAttempts: 3,
59852
60847
  },
59853
60848
  async (scopedDb) =>
@@ -60041,6 +61036,11 @@ export async function failSessionWorkBeforeAttemptClaim(
60041
61036
  const now = new Date();
60042
61037
  let sequence = session.lastSequence;
60043
61038
  let closedToolEvents: SessionEvent[] = [];
61039
+ let cancelledHumanInputs: Array<{
61040
+ id: string;
61041
+ questions: HumanInputQuestion[];
61042
+ turnGeneration: number;
61043
+ }> = [];
60044
61044
  if (turn) {
60045
61045
  await cancelTurnInteractionInterventionsInTransaction(tx as unknown as Database, {
60046
61046
  accountId: session.accountId,
@@ -60062,7 +61062,7 @@ export async function failSessionWorkBeforeAttemptClaim(
60062
61062
  );
60063
61063
  sequence = closedTools.sequence;
60064
61064
  closedToolEvents = closedTools.events;
60065
- const cancelledHumanInputs = await tx
61065
+ cancelledHumanInputs = await tx
60066
61066
  .update(schema.sessionHumanInputRequests)
60067
61067
  .set({
60068
61068
  status: "cancelled",
@@ -60081,6 +61081,7 @@ export async function failSessionWorkBeforeAttemptClaim(
60081
61081
  )
60082
61082
  .returning({
60083
61083
  id: schema.sessionHumanInputRequests.id,
61084
+ questions: schema.sessionHumanInputRequests.questions,
60084
61085
  turnGeneration: schema.sessionHumanInputRequests.turnGeneration,
60085
61086
  });
60086
61087
  // The child's blocked boundary died with the turn: resolve each
@@ -60120,6 +61121,21 @@ export async function failSessionWorkBeforeAttemptClaim(
60120
61121
  );
60121
61122
  }
60122
61123
  const values = [
61124
+ ...(turn
61125
+ ? cancelledHumanInputs.map((request) => ({
61126
+ accountId: session.accountId,
61127
+ workspaceId,
61128
+ sessionId: input.sessionId,
61129
+ sequence: ++sequence,
61130
+ type: "user.humanInputResponse" as const,
61131
+ payload: { requestId: request.id, response: { outcome: "cancelled" as const } },
61132
+ turnId: turn.id,
61133
+ turnGeneration: request.turnGeneration,
61134
+ turnAttemptId: null,
61135
+ turnAssociation: "current" as const,
61136
+ occurredAt: now,
61137
+ }))
61138
+ : []),
60123
61139
  ...(turn
60124
61140
  ? [
60125
61141
  {
@@ -60159,6 +61175,36 @@ export async function failSessionWorkBeforeAttemptClaim(
60159
61175
  .insert(schema.sessionEvents)
60160
61176
  .values(withLosslessContentWriteVersion(values, "payload", "payloadCodecVersion"))
60161
61177
  .returning();
61178
+ const cancelledHumanInputById = new Map(
61179
+ cancelledHumanInputs.map((request) => [request.id, request]),
61180
+ );
61181
+ for (const event of inserted) {
61182
+ if (event.type !== "user.humanInputResponse") continue;
61183
+ const payload = sessionEventPayloadRecord(event.payload, event.payloadCodecVersion);
61184
+ const requestId = typeof payload.requestId === "string" ? payload.requestId : null;
61185
+ const request = requestId ? cancelledHumanInputById.get(requestId) : null;
61186
+ if (!request || !turn) continue;
61187
+ await mirrorSessionRealtimeContextInTransaction(tx as unknown as Database, {
61188
+ accountId: session.accountId,
61189
+ workspaceId,
61190
+ sessionId: input.sessionId,
61191
+ sourceKind: "human_input_response",
61192
+ sourceId: event.id,
61193
+ turnId: turn.id,
61194
+ channel: null,
61195
+ text: renderRealtimeHumanInputResponseContext({
61196
+ requestId: request.id,
61197
+ questions: request.questions,
61198
+ response: { outcome: "cancelled" },
61199
+ }),
61200
+ payload: {
61201
+ requestId: request.id,
61202
+ outcome: "cancelled",
61203
+ sourceEventId: event.id,
61204
+ },
61205
+ now,
61206
+ });
61207
+ }
60162
61208
  if (turn) {
60163
61209
  const failedEvent = inserted.find((event) => event.type === "turn.failed");
60164
61210
  if (!failedEvent) {
@@ -62620,6 +63666,13 @@ export async function requestSessionTurnRecovery(
62620
63666
  if (!updatedSession) {
62621
63667
  throw new Error(`Active session turn changed while locked: ${input.turnId}`);
62622
63668
  }
63669
+ await enqueueSessionWorkflowWakeInTransaction(tx as unknown as Database, {
63670
+ accountId: session.accountId,
63671
+ workspaceId,
63672
+ sessionId: input.sessionId,
63673
+ temporalWorkflowId: session.temporalWorkflowId ?? `session-${input.sessionId}`,
63674
+ reason: "turn_recovery_requested",
63675
+ });
62623
63676
  return {
62624
63677
  action: "recovering" as const,
62625
63678
  events: [...closedTools.events, ...inserted.map(mapEvent)],
@@ -63239,7 +64292,11 @@ export async function getScheduledTargetSessionExecution(
63239
64292
  { latencyMode: latestStarted?.latencyMode },
63240
64293
  session.latencyMode as LatencyMode,
63241
64294
  ),
63242
- tools: (latestStarted?.tools ?? session.tools) as ToolRef[],
64295
+ // A turn stores omitted `tools` as the non-null database default `[]`.
64296
+ // Only the companion marker distinguishes that inherited state from an
64297
+ // explicit empty override, so never let the array alone narrow a later
64298
+ // scheduled occurrence.
64299
+ tools: (latestStarted?.toolsProvided ? latestStarted.tools : session.tools) as ToolRef[],
63243
64300
  sandboxBackend: (latestStarted?.sandboxBackend ?? session.sandboxBackend) as SandboxBackend,
63244
64301
  sandboxOs: (latestStarted?.sandboxOs ?? session.sandboxOs) as SandboxOs,
63245
64302
  firstPartyMcpTools: session.firstPartyMcpTools as FirstPartyMcpToolName[],
@@ -64360,33 +65417,12 @@ export async function markSessionWorkflowWakeDelivered(
64360
65417
  blocker: "pending_agent_steer",
64361
65418
  } as const;
64362
65419
  }
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) {
65420
+ if (
65421
+ await hasPendingSessionAttemptQuiescenceTx(tx as unknown as Database, {
65422
+ workspaceId: input.workspaceId,
65423
+ sessionId: input.sessionId,
65424
+ })
65425
+ ) {
64390
65426
  return {
64391
65427
  action: "pending_admission",
64392
65428
  blocker: "pending_quiescence",
@@ -67885,6 +68921,8 @@ export * from "./attached-browser-devices";
67885
68921
  export * from "./interaction-revisions";
67886
68922
  export * from "./interaction-placement-loss";
67887
68923
  export * from "./canonical-human-identities";
68924
+ export * from "./managed-auth-session-sets";
68925
+ export * from "./organization-recovery";
67888
68926
  export * from "./session-tenancy";
67889
68927
  export * from "./governed-learning-activation";
67890
68928
  export * from "./automations";