@opengeni/db 3.5.2-canary.0 → 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 (47) hide show
  1. package/dist/canonical-human-identities.js +3 -3
  2. package/dist/{chunk-NRGUX3M5.js → chunk-2NT476HD.js} +2 -2
  3. package/dist/{chunk-PERPE2UY.js → chunk-744DFC65.js} +31 -1
  4. package/dist/chunk-744DFC65.js.map +1 -0
  5. package/dist/{chunk-FJHEWYV5.js → chunk-CDMMSUWJ.js} +3 -3
  6. package/dist/{chunk-TRCYF26N.js → chunk-G3732M3T.js} +2 -2
  7. package/dist/{chunk-ORKVXHL2.js → chunk-IW6O6G7R.js} +2 -2
  8. package/dist/{chunk-Y44FKTYU.js → chunk-Q5YUGQVK.js} +3 -3
  9. package/dist/{chunk-U7NKCX5E.js → chunk-UGDVSQ6U.js} +2 -2
  10. package/dist/{chunk-6NYKC5DD.js → chunk-UJ2EW3QI.js} +4729 -4583
  11. package/dist/chunk-UJ2EW3QI.js.map +1 -0
  12. package/dist/{chunk-X6T2P3L3.js → chunk-WW7AJ5IC.js} +2 -2
  13. package/dist/editable-artifact-durable-export.js +2 -2
  14. package/dist/editable-artifacts.js +3 -3
  15. package/dist/index.d.ts +23 -3
  16. package/dist/index.js +2112 -1436
  17. package/dist/index.js.map +1 -1
  18. package/dist/managed-auth-session-sets.js +3 -3
  19. package/dist/provision-roles.js +1 -1
  20. package/dist/runtime-posture.d.ts +5 -4
  21. package/dist/schema.d.ts +7 -6
  22. package/dist/schema.js +7 -1
  23. package/dist/session-background-commands.js +3 -3
  24. package/dist/session-tenancy.js +3 -3
  25. package/dist/video-generation.js +3 -3
  26. package/dist/work-claims-schema.d.ts +975 -0
  27. package/dist/work-claims.d.ts +83 -0
  28. package/drizzle/0365_permission_scoped_work_claims.sql +1306 -0
  29. package/drizzle/0366_session_discovery_title_search_index.sql +5 -0
  30. package/drizzle/0367_session_discovery_goal_search_index.sql +5 -0
  31. package/drizzle/0368_session_discovery_claim_search_index.sql +6 -0
  32. package/package.json +6 -6
  33. package/src/index.ts +1051 -330
  34. package/src/provision-roles.ts +18 -0
  35. package/src/runtime-posture.ts +12 -0
  36. package/src/schema.ts +1 -0
  37. package/src/work-claims-schema.ts +148 -0
  38. package/src/work-claims.ts +200 -0
  39. package/dist/chunk-6NYKC5DD.js.map +0 -1
  40. package/dist/chunk-PERPE2UY.js.map +0 -1
  41. /package/dist/{chunk-NRGUX3M5.js.map → chunk-2NT476HD.js.map} +0 -0
  42. /package/dist/{chunk-FJHEWYV5.js.map → chunk-CDMMSUWJ.js.map} +0 -0
  43. /package/dist/{chunk-TRCYF26N.js.map → chunk-G3732M3T.js.map} +0 -0
  44. /package/dist/{chunk-ORKVXHL2.js.map → chunk-IW6O6G7R.js.map} +0 -0
  45. /package/dist/{chunk-Y44FKTYU.js.map → chunk-Q5YUGQVK.js.map} +0 -0
  46. /package/dist/{chunk-U7NKCX5E.js.map → chunk-UGDVSQ6U.js.map} +0 -0
  47. /package/dist/{chunk-X6T2P3L3.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";
@@ -30823,9 +30840,11 @@ export async function getLatestSessionModelForSubject(
30823
30840
  });
30824
30841
  }
30825
30842
 
30826
- export type SessionDiscoveryOrderBy = "createdAt" | "updatedAt";
30843
+ export type SessionDiscoveryOrderBy = "createdAt" | "updatedAt" | "relevance";
30827
30844
  export type SessionDiscoveryCursor = {
30828
30845
  orderBy: SessionDiscoveryOrderBy;
30846
+ /** Stable rank class for relevance order; null for chronological order. */
30847
+ sortRank: number | null;
30829
30848
  /** Decimal database activity revision; zero is the legacy/created-order bucket. */
30830
30849
  sortRevision: string;
30831
30850
  /** Exact PostgreSQL timestamp text (including microseconds), not a JS Date. */
@@ -30837,6 +30856,8 @@ export type SessionDiscoveryCursor = {
30837
30856
  snapshotRevision: string;
30838
30857
  /** Incremental revision scope is cursor-bound and cannot change on continuation. */
30839
30858
  updatedAfter: string | null;
30859
+ /** SHA-256 of normalized relevance filters; null for chronological order. */
30860
+ filterHash: string | null;
30840
30861
  };
30841
30862
  export const SESSION_DISCOVERY_GOAL_MAX_CHARS = 600;
30842
30863
  export const SESSION_DISCOVERY_MESSAGE_MAX_CHARS = 600;
@@ -30861,12 +30882,15 @@ export type SessionDiscoverySummary = {
30861
30882
  preview: string | null;
30862
30883
  previewOriginalChars: number | null;
30863
30884
  } | null;
30885
+ workDiscovery: WorkDiscoveryProjection;
30864
30886
  createdAt: string;
30865
30887
  updatedAt: string;
30866
30888
  /** Internal exact revision key; omitted from the MCP row projection. */
30867
30889
  sortRevision: string;
30868
30890
  /** Internal exact keyset timestamp; omitted from the MCP row projection. */
30869
30891
  sortAt: string;
30892
+ /** Internal stable relevance class; omitted from public row projections. */
30893
+ sortRank: number | null;
30870
30894
  };
30871
30895
 
30872
30896
  function projectSessionDiscoveryControlForRelatedAccess(
@@ -30925,6 +30949,700 @@ async function readWorkspaceSessionActivityRevision(
30925
30949
  return normalizeSessionActivityRevision(revision, "snapshot revision");
30926
30950
  }
30927
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
+
30928
31646
  /**
30929
31647
  * Excludes a human/API `user.message` whose accepted turn was never claimed.
30930
31648
  * Such a prompt was never model input: it is waiting work (represented by
@@ -30988,7 +31706,17 @@ export async function listSessionDiscoverySummaries(
30988
31706
  orderBy?: SessionDiscoveryOrderBy;
30989
31707
  updatedAfter?: string;
30990
31708
  parentSessionId?: string | null;
31709
+ rootSessionId?: string;
31710
+ query?: string;
31711
+ /** @deprecated use query; retained for the existing topology route. */
30991
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;
30992
31720
  subjectId?: string;
30993
31721
  authorizationScope?: SessionAuthorizationListScope;
30994
31722
  },
@@ -31002,330 +31730,306 @@ export async function listSessionDiscoverySummaries(
31002
31730
  snapshotRevision: string;
31003
31731
  updatedThrough: string | null;
31004
31732
  updatedAfter: string | null;
31733
+ /** Internal relevance cursor binding; omitted from row projections. */
31734
+ filterHash: string | null;
31005
31735
  }> {
31006
31736
  const limit = Math.max(1, Math.min(100, Math.floor(options.limit)));
31007
- const orderBy = options.orderBy ?? options.cursor?.orderBy ?? "createdAt";
31008
- return await withWorkspaceRls(
31009
- db,
31010
- workspaceId,
31011
- async (scopedDb) => {
31012
- const requestedUpdatedAfter = options.updatedAfter ?? options.cursor?.updatedAfter ?? null;
31013
- const updatedAfter =
31014
- requestedUpdatedAfter === null
31015
- ? null
31016
- : normalizeSessionActivityRevision(requestedUpdatedAfter, "updatedAfter");
31017
- if (options.cursor?.orderBy !== undefined && options.cursor.orderBy !== orderBy) {
31018
- throw new Error("sessions_list cursor order does not match the request");
31019
- }
31020
- if (options.cursor && options.cursor.updatedAfter !== updatedAfter) {
31021
- throw new Error("sessions_list cursor incremental filter does not match the request");
31022
- }
31023
- if (updatedAfter !== null && orderBy !== "updatedAt") {
31024
- throw new Error("sessions_list updatedAfter requires orderBy=updatedAt");
31025
- }
31026
- const snapshotAt =
31027
- options.cursor?.snapshotAt ??
31028
- (
31029
- await rawRows<{ value: string }>(
31030
- scopedDb,
31031
- // The transaction-start cutoff is no later than any statement or
31032
- // repeatable-read snapshot used below. Later rows therefore belong
31033
- // to the next traversal instead of entering this cursor mid-scan.
31034
- sql`select to_char(transaction_timestamp() at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') as value`,
31035
- )
31036
- )[0]!.value;
31037
- const snapshotRevision =
31038
- orderBy === "updatedAt"
31039
- ? options.cursor?.snapshotRevision !== undefined
31040
- ? normalizeSessionActivityRevision(
31041
- options.cursor.snapshotRevision,
31042
- "cursor snapshot revision",
31043
- )
31044
- : await readWorkspaceSessionActivityRevision(scopedDb, workspaceId)
31045
- : "0";
31046
- const cursorSortRevision = options.cursor
31047
- ? normalizeSessionActivityRevision(options.cursor.sortRevision, "cursor sort revision")
31048
- : 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") {
31049
31771
  if (
31050
- orderBy === "createdAt" &&
31051
- options.cursor &&
31052
- (cursorSortRevision !== "0" || options.cursor.snapshotRevision !== "0")
31772
+ !Number.isSafeInteger(options.cursor.sortRank) ||
31773
+ options.cursor.sortRank === null ||
31774
+ options.cursor.sortRank < 0
31053
31775
  ) {
31054
- throw new Error("sessions_list created-order cursor cannot carry activity revisions");
31776
+ throw new Error("sessions_list relevance cursor rank is invalid");
31055
31777
  }
31056
- const cursorPredicate = options.cursor
31057
- ? orderBy === "updatedAt"
31058
- ? or(
31059
- sql`${schema.sessions.activityRevision} < ${cursorSortRevision!}::text::bigint`,
31060
- and(
31061
- sql`${schema.sessions.activityRevision} = ${cursorSortRevision!}::text::bigint`,
31062
- or(
31063
- sql`${schema.sessions.updatedAt} < ${options.cursor.sortAt}::text::timestamptz`,
31064
- and(
31065
- sql`${schema.sessions.updatedAt} = ${options.cursor.sortAt}::text::timestamptz`,
31066
- lt(schema.sessions.id, options.cursor.id),
31067
- ),
31068
- ),
31069
- ),
31070
- )
31071
- : or(
31072
- // Cast through text deliberately. postgres.js otherwise infers a
31073
- // timestamptz parameter and serializes this exact cursor string via
31074
- // JS Date, which discards PostgreSQL's sub-millisecond precision.
31075
- sql`${schema.sessions.createdAt} < ${options.cursor.sortAt}::text::timestamptz`,
31076
- and(
31077
- sql`${schema.sessions.createdAt} = ${options.cursor.sortAt}::text::timestamptz`,
31078
- lt(schema.sessions.id, options.cursor.id),
31079
- ),
31080
- )
31081
- : undefined;
31082
- const snapshotFilters: SQL[] = [eq(schema.sessions.workspaceId, workspaceId)];
31083
- if (options.subjectId) {
31084
- snapshotFilters.push(sql`not exists (
31085
- select 1
31086
- from ${schema.slackInteractions} private_slack_interaction
31087
- where private_slack_interaction.workspace_id = ${schema.sessions.workspaceId}
31088
- and private_slack_interaction.session_reservation_id = ${schema.sessions.rootSessionId}
31089
- and private_slack_interaction.visibility = 'private'
31090
- and private_slack_interaction.owning_subject_id <> ${options.subjectId}
31091
- )`);
31778
+ if (options.cursor.filterHash !== filterHash) {
31779
+ throw new Error("sessions_list cursor relevance filters do not match the request");
31092
31780
  }
31093
- if (options.authorizationScope) {
31094
- snapshotFilters.push(sessionAuthorizationScopeFilter(options.authorizationScope));
31095
- }
31096
- if (Object.prototype.hasOwnProperty.call(options, "parentSessionId")) {
31097
- snapshotFilters.push(
31098
- options.parentSessionId === null
31099
- ? isNull(schema.sessions.parentSessionId)
31100
- : eq(schema.sessions.parentSessionId, options.parentSessionId!),
31101
- );
31102
- }
31103
- const search = options.search?.trim();
31104
- if (search) {
31105
- const pattern = `%${search
31106
- .replaceAll("\\", "\\\\")
31107
- .replaceAll("%", "\\%")
31108
- .replaceAll("_", "\\_")}%`;
31109
- snapshotFilters.push(
31110
- or(
31111
- ilike(schema.sessions.title, pattern),
31112
- ilike(schema.sessions.initialMessage, pattern),
31113
- )!,
31114
- );
31115
- }
31116
- snapshotFilters.push(
31117
- orderBy === "updatedAt"
31118
- ? sql`${schema.sessions.activityRevision} <= ${snapshotRevision}::text::bigint`
31119
- : sql`${schema.sessions.createdAt} <= ${snapshotAt}::text::timestamptz`,
31120
- );
31121
- if (updatedAfter !== null) {
31122
- snapshotFilters.push(
31123
- sql`${schema.sessions.activityRevision} > ${updatedAfter}::text::bigint`,
31124
- );
31125
- }
31126
- const rows = await scopedDb
31127
- .select({
31128
- id: schema.sessions.id,
31129
- title: sql<
31130
- string | null
31131
- >`left(${schema.sessions.title}, ${SESSION_DISCOVERY_CONTROL_TITLE_MAX_CHARS})`,
31132
- titleOriginalChars: sql<number | null>`char_length(${schema.sessions.title})::integer`,
31133
- parentSessionId: schema.sessions.parentSessionId,
31134
- rootSessionId: schema.sessions.rootSessionId,
31135
- nestedAgentDepth: schema.sessions.nestedAgentDepth,
31136
- status: schema.sessions.status,
31137
- createdAt: schema.sessions.createdAt,
31138
- updatedAt: schema.sessions.updatedAt,
31139
- sortRevision:
31140
- orderBy === "updatedAt"
31141
- ? sql<string>`${schema.sessions.activityRevision}::text`
31142
- : sql<string>`'0'`,
31143
- sortAt:
31144
- orderBy === "updatedAt"
31145
- ? sql<string>`to_char(${schema.sessions.updatedAt} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`
31146
- : sql<string>`to_char(${schema.sessions.createdAt} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`,
31147
- })
31148
- .from(schema.sessions)
31149
- .where(and(...snapshotFilters, cursorPredicate))
31150
- .orderBy(
31151
- ...(orderBy === "updatedAt"
31152
- ? [
31153
- desc(schema.sessions.activityRevision),
31154
- desc(schema.sessions.updatedAt),
31155
- desc(schema.sessions.id),
31156
- ]
31157
- : [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`,
31158
31796
  )
31159
- .limit(limit + 1);
31160
- const hasMore = rows.length > limit;
31161
- const page = rows.slice(0, limit);
31162
- const ids = page.map((row) => row.id);
31163
- const [{ total } = { total: 0 }] = await scopedDb
31164
- .select({ total: sql<number>`count(*)::int` })
31165
- .from(schema.sessions)
31166
- .where(and(...snapshotFilters));
31167
- if (ids.length === 0) {
31168
- return {
31169
- sessions: [],
31170
- hasMore: false,
31171
- nextCursor: null,
31172
- total: Number(total),
31173
- orderBy,
31174
- snapshotAt,
31175
- snapshotRevision,
31176
- updatedThrough: orderBy === "updatedAt" ? snapshotRevision : null,
31177
- updatedAfter,
31178
- };
31179
- }
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
+ }
31180
31848
 
31181
- const rootRelatedIds = await sessionIdsCoveredByAuthorizationRoots(
31182
- scopedDb,
31183
- workspaceId,
31184
- ids,
31185
- options.authorizationScope,
31186
- );
31187
- const controls = await evaluateSessionDiscoveryControls(scopedDb, workspaceId, ids);
31188
- const treeStats = await sessionTreeStatsForSessions(scopedDb, workspaceId, [
31189
- ...rootRelatedIds,
31190
- ]);
31191
- const goals = await scopedDb
31192
- .select({
31193
- sessionId: schema.sessionGoals.sessionId,
31194
- status: schema.sessionGoals.status,
31195
- text: sql<string>`left(${schema.sessionGoals.text}, ${SESSION_DISCOVERY_GOAL_MAX_CHARS})`,
31196
- textOriginalChars: sql<number>`char_length(${schema.sessionGoals.text})::integer`,
31197
- })
31198
- .from(schema.sessionGoals)
31199
- .where(
31200
- and(
31201
- eq(schema.sessionGoals.workspaceId, workspaceId),
31202
- inArray(schema.sessionGoals.sessionId, ids),
31203
- ),
31204
- );
31205
- const goalsBySession = new Map(goals.map((goal) => [goal.sessionId, goal]));
31206
- const queueCounts = await scopedDb
31207
- .select({
31208
- sessionId: schema.sessionTurns.sessionId,
31209
- 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)),
31210
31865
  })
31211
- .from(schema.sessionTurns)
31212
- .where(
31213
- and(
31214
- eq(schema.sessionTurns.workspaceId, workspaceId),
31215
- inArray(schema.sessionTurns.sessionId, ids),
31216
- eq(schema.sessionTurns.status, "queued"),
31217
- inArray(schema.sessionTurns.source, ["user", "api"]),
31218
- ),
31219
- )
31220
- .groupBy(schema.sessionTurns.sessionId);
31221
- const queueBySession = new Map(
31222
- 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
+ ),
31223
31880
  );
31224
- const latestMessages = options.includeLastMessage
31225
- ? await scopedDb
31226
- .selectDistinctOn([schema.sessionEvents.sessionId], {
31227
- sessionId: schema.sessionEvents.sessionId,
31228
- type: schema.sessionEvents.type,
31229
- // Extract only the bounded textual preview in PostgreSQL. Selecting
31230
- // the JSON payload here would re-materialize the exact multi-MB
31231
- // event bodies this compact discovery path exists to avoid.
31232
- 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(
31233
31909
  ${schema.sessionEvents.payload}->>'text',
31234
31910
  ${schema.sessionEvents.payload}->>'message',
31235
31911
  ${schema.sessionEvents.payload}->>'content'
31236
31912
  ), ${SESSION_DISCOVERY_MESSAGE_MAX_CHARS})`,
31237
- previewOriginalChars: sql<number | null>`char_length(coalesce(
31913
+ previewOriginalChars: sql<number | null>`char_length(coalesce(
31238
31914
  ${schema.sessionEvents.payload}->>'text',
31239
31915
  ${schema.sessionEvents.payload}->>'message',
31240
31916
  ${schema.sessionEvents.payload}->>'content'
31241
31917
  ))::integer`,
31242
- })
31243
- .from(schema.sessionEvents)
31244
- .where(
31245
- and(
31246
- eq(schema.sessionEvents.workspaceId, workspaceId),
31247
- inArray(schema.sessionEvents.sessionId, ids),
31248
- inArray(schema.sessionEvents.type, ["user.message", "agent.message.completed"]),
31249
- excludeUnclaimedHumanPromptEventFilter(workspaceId),
31250
- ),
31251
- )
31252
- .orderBy(schema.sessionEvents.sessionId, desc(schema.sessionEvents.sequence))
31253
- : [];
31254
- const latestBySession = new Map(latestMessages.map((entry) => [entry.sessionId, entry]));
31255
- const sessions = page.map((row): SessionDiscoverySummary => {
31256
- const control = controls.get(row.id);
31257
- if (!control) throw new Error(`Effective control missing for session ${row.id}`);
31258
- const relatedAccess = rootRelatedIds.has(row.id) ? "root" : "target";
31259
- const goal = goalsBySession.get(row.id);
31260
- const latest = latestBySession.get(row.id);
31261
- return {
31262
- id: row.id,
31263
- title: row.title,
31264
- titleOriginalChars:
31265
- row.titleOriginalChars === null ? null : Number(row.titleOriginalChars),
31266
- parentSessionId: relatedAccess === "root" ? row.parentSessionId : null,
31267
- rootSessionId: relatedAccess === "root" ? row.rootSessionId : row.id,
31268
- nestedAgentDepth: row.nestedAgentDepth,
31269
- status: row.status as SessionStatus,
31270
- effectiveControl: projectSessionDiscoveryControlForRelatedAccess(
31271
- control,
31272
- row.id,
31273
- relatedAccess,
31274
- ),
31275
- goal: goal
31276
- ? {
31277
- status: goal.status as SessionGoalStatus,
31278
- text: goal.text,
31279
- textOriginalChars: Number(goal.textOriginalChars),
31280
- }
31281
- : null,
31282
- queuedPromptCount: queueBySession.get(row.id) ?? 0,
31283
- treeStats:
31284
- relatedAccess === "root"
31285
- ? (treeStats.get(row.id) ?? EMPTY_SESSION_TREE_STATS)
31286
- : EMPTY_SESSION_TREE_STATS,
31287
- latestMessage: latest
31288
- ? {
31289
- type: latest.type as SessionEventType,
31290
- preview: latest.preview,
31291
- previewOriginalChars:
31292
- latest.previewOriginalChars === null ? null : Number(latest.previewOriginalChars),
31293
- }
31294
- : null,
31295
- createdAt: row.createdAt.toISOString(),
31296
- updatedAt: row.updatedAt.toISOString(),
31297
- sortRevision: row.sortRevision,
31298
- sortAt: row.sortAt,
31299
- };
31300
- });
31301
- 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
+ });
31302
31951
  return {
31303
- sessions,
31304
- hasMore,
31305
- nextCursor:
31306
- hasMore && last
31307
- ? {
31308
- orderBy,
31309
- sortRevision: last.sortRevision,
31310
- sortAt: last.sortAt,
31311
- id: last.id,
31312
- snapshotAt,
31313
- snapshotRevision,
31314
- updatedAfter,
31315
- }
31316
- : null,
31317
- total: Number(total),
31318
- orderBy,
31319
- snapshotAt,
31320
- snapshotRevision,
31321
- updatedThrough: orderBy === "updatedAt" ? snapshotRevision : null,
31322
- 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),
31323
31997
  };
31324
- },
31325
- orderBy === "updatedAt"
31326
- ? { isolationLevel: "repeatable read", accessMode: "read only" }
31327
- : { isolationLevel: "read committed", accessMode: "read only" },
31328
- );
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);
31329
32033
  }
31330
32034
 
31331
32035
  export type SessionDiscoveryAncestor = {
@@ -31344,10 +32048,34 @@ export async function listSessionDiscoveryAncestorPaths(
31344
32048
  workspaceId: string,
31345
32049
  sessionIds: string[],
31346
32050
  authorizationScope?: SessionAuthorizationListScope,
32051
+ subjectId?: string,
31347
32052
  ): Promise<Map<string, SessionDiscoveryAncestor[]>> {
31348
32053
  const targetIds = [...new Set(sessionIds)].slice(0, 100);
31349
32054
  if (targetIds.length === 0) return new Map();
31350
- 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`;
31351
32079
  const rows = await rawRows<{
31352
32080
  targetId: string;
31353
32081
  id: string;
@@ -31358,7 +32086,15 @@ export async function listSessionDiscoveryAncestorPaths(
31358
32086
  }>(
31359
32087
  scopedDb,
31360
32088
  sql`
31361
- 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 (
31362
32098
  select
31363
32099
  target.id as target_id,
31364
32100
  target.id,
@@ -31367,9 +32103,8 @@ export async function listSessionDiscoveryAncestorPaths(
31367
32103
  0::integer as depth,
31368
32104
  array[target.id]::uuid[] as path,
31369
32105
  false as cycle
31370
- from ${schema.sessions} target
31371
- where target.workspace_id = ${workspaceId}
31372
- and ${inArray(sql`target.id`, targetIds)}
32106
+ from authorized_sessions target
32107
+ where ${inArray(sql`target.id`, targetIds)}
31373
32108
 
31374
32109
  union all
31375
32110
 
@@ -31382,9 +32117,7 @@ export async function listSessionDiscoveryAncestorPaths(
31382
32117
  child.path || parent.id,
31383
32118
  parent.id = any(child.path)
31384
32119
  from lineage child
31385
- join ${schema.sessions} parent
31386
- on parent.workspace_id = ${workspaceId}
31387
- and parent.id = child.parent_session_id
32120
+ join authorized_sessions parent on parent.id = child.parent_session_id
31388
32121
  where not child.cycle
31389
32122
  and child.depth < 64
31390
32123
  )
@@ -31400,24 +32133,9 @@ export async function listSessionDiscoveryAncestorPaths(
31400
32133
  order by target_id, depth desc
31401
32134
  `,
31402
32135
  );
31403
- const candidateIds = [...new Set(rows.filter((row) => !row.cycle).map((row) => row.id))];
31404
- let allowed = new Set(candidateIds);
31405
- if (authorizationScope && authorizationScope.kind !== "all" && candidateIds.length > 0) {
31406
- const allowedRows = await scopedDb
31407
- .select({ id: schema.sessions.id })
31408
- .from(schema.sessions)
31409
- .where(
31410
- and(
31411
- eq(schema.sessions.workspaceId, workspaceId),
31412
- inArray(schema.sessions.id, candidateIds),
31413
- sessionAuthorizationScopeFilter(authorizationScope),
31414
- ),
31415
- );
31416
- allowed = new Set(allowedRows.map((row) => row.id));
31417
- }
31418
32136
  const result = new Map<string, SessionDiscoveryAncestor[]>();
31419
32137
  for (const row of rows) {
31420
- if (row.cycle || !allowed.has(row.id)) continue;
32138
+ if (row.cycle) continue;
31421
32139
  const path = result.get(row.targetId) ?? [];
31422
32140
  path.push({
31423
32141
  id: row.id,
@@ -31427,7 +32145,10 @@ export async function listSessionDiscoveryAncestorPaths(
31427
32145
  result.set(row.targetId, path);
31428
32146
  }
31429
32147
  return result;
31430
- });
32148
+ };
32149
+ return subjectId
32150
+ ? await withWorkspaceSubjectRls(db, workspaceId, subjectId, readPaths)
32151
+ : await withWorkspaceRls(db, workspaceId, readPaths);
31431
32152
  }
31432
32153
 
31433
32154
  export type SessionLineage = {