@opengeni/db 4.4.1 → 4.5.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 (82) hide show
  1. package/dist/canonical-human-identities.js +3 -3
  2. package/dist/capability-reconciliation.js +2 -2
  3. package/dist/{chunk-T7GDRZOH.js → chunk-4LVV4R4F.js} +2 -2
  4. package/dist/{chunk-NQWVBQFK.js → chunk-575FUJE2.js} +3 -3
  5. package/dist/{chunk-K2XOMTAG.js → chunk-7LFHUC35.js} +2 -2
  6. package/dist/{chunk-JC43DLHB.js → chunk-7P67EF5K.js} +5 -5
  7. package/dist/{chunk-XWANFAN4.js → chunk-BH2HBICR.js} +4 -4
  8. package/dist/{chunk-PB73JQOI.js → chunk-C3FI3DJ5.js} +2 -2
  9. package/dist/{chunk-LLQLCFCT.js → chunk-GSZZDYPV.js} +2 -2
  10. package/dist/{chunk-B4DWUU4Y.js → chunk-GYAQYECK.js} +2 -2
  11. package/dist/{chunk-GYHXV2LR.js → chunk-K2YN562I.js} +2 -2
  12. package/dist/{chunk-6UJX2OXO.js → chunk-KMBU4KDY.js} +25 -1
  13. package/dist/chunk-KMBU4KDY.js.map +1 -0
  14. package/dist/{chunk-JN2DIKGW.js → chunk-M6TNKUGE.js} +3 -1
  15. package/dist/chunk-M6TNKUGE.js.map +1 -0
  16. package/dist/{chunk-SSCUP23X.js → chunk-MSAWMGWJ.js} +6 -6
  17. package/dist/{chunk-T5RZ3HWS.js → chunk-OFQDECMZ.js} +4 -4
  18. package/dist/{chunk-7UA4WUGK.js → chunk-RRZO5XRA.js} +2671 -2335
  19. package/dist/{chunk-7UA4WUGK.js.map → chunk-RRZO5XRA.js.map} +1 -1
  20. package/dist/{chunk-72JNABPO.js → chunk-TIF52FMJ.js} +3 -3
  21. package/dist/{chunk-67VNEM5N.js → chunk-TLCTS7CZ.js} +4 -4
  22. package/dist/{chunk-VFQI3CDL.js → chunk-VXCIFQX7.js} +2 -2
  23. package/dist/{chunk-5H6FHFHD.js → chunk-XX5M3TJJ.js} +2 -2
  24. package/dist/{chunk-LUO43IZE.js → chunk-YOFYG5ZI.js} +2 -2
  25. package/dist/connect-attempts.js +3 -3
  26. package/dist/editable-artifact-durable-export.js +2 -2
  27. package/dist/editable-artifacts.js +3 -3
  28. package/dist/index.d.ts +21 -0
  29. package/dist/index.js +25 -19
  30. package/dist/managed-auth-session-sets.js +3 -3
  31. package/dist/mcp-operations.js +2 -2
  32. package/dist/migrate.js +19 -19
  33. package/dist/organization-integration-policy.js +3 -3
  34. package/dist/provision-roles.js +1 -1
  35. package/dist/retained-provider-commands.js +6 -6
  36. package/dist/runtime-posture.d.ts +2 -2
  37. package/dist/schema.d.ts +93 -0
  38. package/dist/schema.js +3 -1
  39. package/dist/session-background-commands.js +5 -5
  40. package/dist/session-command-output.js +5 -5
  41. package/dist/session-event-slices.js +3 -3
  42. package/dist/session-history-tool-identities.d.ts +11 -0
  43. package/dist/session-mcp-credential-rotation.js +6 -6
  44. package/dist/session-message-search.d.ts +21 -0
  45. package/dist/session-tenancy.js +6 -6
  46. package/dist/{subscription-core-codex-SUO2TISD.js → subscription-core-codex-L7NPFWAH.js} +6 -6
  47. package/dist/{subscription-core-codex-compat-SDHM4XG4.js → subscription-core-codex-compat-AK2UMLE3.js} +8 -8
  48. package/dist/subscription-usage-pool.d.ts +1 -1
  49. package/dist/video-generation.js +3 -3
  50. package/dist/workspace-artifacts.d.ts +13 -0
  51. package/dist/workspace-tool-defaults.js +5 -5
  52. package/drizzle/0907_subscription_connection_one_owner.sql +339 -209
  53. package/drizzle/0908_site_opens.sql +33 -0
  54. package/package.json +9 -9
  55. package/src/index.ts +304 -44
  56. package/src/runtime-posture.ts +2 -0
  57. package/src/schema.ts +24 -0
  58. package/src/session-history-tool-identities.ts +55 -0
  59. package/src/session-message-search.ts +188 -33
  60. package/src/subscription-core/administered-connections.ts +7 -5
  61. package/src/subscription-usage-pool.ts +7 -5
  62. package/src/workspace-artifacts.ts +45 -1
  63. package/dist/chunk-6UJX2OXO.js.map +0 -1
  64. package/dist/chunk-JN2DIKGW.js.map +0 -1
  65. /package/dist/{chunk-T7GDRZOH.js.map → chunk-4LVV4R4F.js.map} +0 -0
  66. /package/dist/{chunk-NQWVBQFK.js.map → chunk-575FUJE2.js.map} +0 -0
  67. /package/dist/{chunk-K2XOMTAG.js.map → chunk-7LFHUC35.js.map} +0 -0
  68. /package/dist/{chunk-JC43DLHB.js.map → chunk-7P67EF5K.js.map} +0 -0
  69. /package/dist/{chunk-XWANFAN4.js.map → chunk-BH2HBICR.js.map} +0 -0
  70. /package/dist/{chunk-PB73JQOI.js.map → chunk-C3FI3DJ5.js.map} +0 -0
  71. /package/dist/{chunk-LLQLCFCT.js.map → chunk-GSZZDYPV.js.map} +0 -0
  72. /package/dist/{chunk-B4DWUU4Y.js.map → chunk-GYAQYECK.js.map} +0 -0
  73. /package/dist/{chunk-GYHXV2LR.js.map → chunk-K2YN562I.js.map} +0 -0
  74. /package/dist/{chunk-SSCUP23X.js.map → chunk-MSAWMGWJ.js.map} +0 -0
  75. /package/dist/{chunk-T5RZ3HWS.js.map → chunk-OFQDECMZ.js.map} +0 -0
  76. /package/dist/{chunk-72JNABPO.js.map → chunk-TIF52FMJ.js.map} +0 -0
  77. /package/dist/{chunk-67VNEM5N.js.map → chunk-TLCTS7CZ.js.map} +0 -0
  78. /package/dist/{chunk-VFQI3CDL.js.map → chunk-VXCIFQX7.js.map} +0 -0
  79. /package/dist/{chunk-5H6FHFHD.js.map → chunk-XX5M3TJJ.js.map} +0 -0
  80. /package/dist/{chunk-LUO43IZE.js.map → chunk-YOFYG5ZI.js.map} +0 -0
  81. /package/dist/{subscription-core-codex-SUO2TISD.js.map → subscription-core-codex-L7NPFWAH.js.map} +0 -0
  82. /package/dist/{subscription-core-codex-compat-SDHM4XG4.js.map → subscription-core-codex-compat-AK2UMLE3.js.map} +0 -0
@@ -0,0 +1,33 @@
1
+ -- deployment-mode: rolling
2
+ -- When each person last opened a Site in the console. It orders that
3
+ -- person's Site rows in the sidebar like session activity and grants
4
+ -- nothing: the Site's own audience still decides who can see it. New code
5
+ -- owns the table; old binaries never read it.
6
+ CREATE TABLE site_opens (
7
+ account_id uuid NOT NULL,
8
+ workspace_id uuid NOT NULL,
9
+ subject_id text NOT NULL CHECK (length(subject_id) BETWEEN 1 AND 512),
10
+ site_id uuid NOT NULL,
11
+ opened_at timestamptz NOT NULL DEFAULT now(),
12
+ PRIMARY KEY (workspace_id, subject_id, site_id),
13
+ FOREIGN KEY (workspace_id, account_id) REFERENCES workspaces(id, account_id) ON DELETE CASCADE,
14
+ FOREIGN KEY (workspace_id, site_id) REFERENCES workspace_artifacts(workspace_id, id) ON DELETE CASCADE
15
+ );
16
+ CREATE INDEX site_opens_site_idx ON site_opens(workspace_id, site_id);
17
+ ALTER TABLE site_opens ENABLE ROW LEVEL SECURITY;
18
+ ALTER TABLE site_opens FORCE ROW LEVEL SECURITY;
19
+ -- Personal like session pins: only the opening subject reads or writes a row.
20
+ CREATE POLICY workspace_isolation ON site_opens
21
+ USING (
22
+ opengeni_private.workspace_rls_visible(account_id, workspace_id)
23
+ AND subject_id = opengeni_private.current_subject_id()
24
+ )
25
+ WITH CHECK (
26
+ opengeni_private.workspace_rls_visible(account_id, workspace_id)
27
+ AND subject_id = opengeni_private.current_subject_id()
28
+ );
29
+ DO $$ BEGIN
30
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
31
+ GRANT SELECT, INSERT, UPDATE, DELETE ON site_opens TO opengeni_app;
32
+ END IF;
33
+ END $$;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/db",
3
- "version": "4.4.1",
3
+ "version": "4.5.0",
4
4
  "description": "Opengeni persistence: Drizzle schema, RLS-scoped query layer, the SQL migration runner, and role provisioning.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -121,14 +121,14 @@
121
121
  "prepublishOnly": "bash ../../scripts/prepublish-guard"
122
122
  },
123
123
  "dependencies": {
124
- "@opengeni/codemode": "4.4.1",
125
- "@opengeni/codex": "4.4.1",
126
- "@opengeni/config": "4.4.1",
127
- "@opengeni/contracts": "4.4.1",
128
- "@opengeni/network": "4.4.1",
129
- "@opengeni/observability": "4.4.1",
130
- "@opengeni/subscriptions": "4.4.1",
131
- "@opengeni/xai-subscription": "4.4.1",
124
+ "@opengeni/codemode": "4.5.0",
125
+ "@opengeni/codex": "4.5.0",
126
+ "@opengeni/config": "4.5.0",
127
+ "@opengeni/contracts": "4.5.0",
128
+ "@opengeni/network": "4.5.0",
129
+ "@opengeni/observability": "4.5.0",
130
+ "@opengeni/subscriptions": "4.5.0",
131
+ "@opengeni/xai-subscription": "4.5.0",
132
132
  "drizzle-orm": "^0.45.2",
133
133
  "postgres": "^3.4.7"
134
134
  },
package/src/index.ts CHANGED
@@ -783,6 +783,7 @@ import type {
783
783
  SessionMemoryScope,
784
784
  SessionListResponse,
785
785
  SessionListTotals,
786
+ SiteConversationGroup,
786
787
  SessionListEntryResponse,
787
788
  SessionTenancyPublicProjection,
788
789
  SessionEvent,
@@ -1057,6 +1058,7 @@ import {
1057
1058
  historyItemType,
1058
1059
  TOOL_RESULT_TYPE_BY_CALL_TYPE,
1059
1060
  } from "./session-tool-call-settlement";
1061
+ import { readSessionHistoryToolIdentityPage } from "./session-history-tool-identities";
1060
1062
  import {
1061
1063
  assertAgentCommandAuthorityInTransaction,
1062
1064
  canonicalSessionCommandHash,
@@ -29996,6 +29998,37 @@ function wellFormedDriverText(value: unknown): unknown {
29996
29998
  return Array.isArray(value) ? value.map(wellFormedDriverText) : value;
29997
29999
  }
29998
30000
 
30001
+ /**
30002
+ * A new Site conversation is filed in the project of the chat that created the
30003
+ * Site (the tree of its first version's session), so the Site's sidebar row and
30004
+ * its conversations sit together. Without a Site origin, or a creating chat,
30005
+ * the session stays unfiled.
30006
+ */
30007
+ async function siteHomeChannelId(
30008
+ tx: Database,
30009
+ workspaceId: string,
30010
+ metadata: Record<string, unknown>,
30011
+ ): Promise<string | null> {
30012
+ const origin = metadata._opengeniSiteOrigin;
30013
+ const siteId =
30014
+ origin && typeof origin === "object" ? (origin as { siteId?: unknown }).siteId : undefined;
30015
+ if (typeof siteId !== "string" || !z.string().uuid().safeParse(siteId).success) return null;
30016
+ const [home] = await rawRows<{ channelId: string | null }>(
30017
+ tx,
30018
+ sql`
30019
+ select root.channel_id::text as "channelId"
30020
+ from ${schema.workspaceArtifactVersions} version
30021
+ join ${schema.sessions} source
30022
+ on source.workspace_id = version.workspace_id and source.id = version.source_session_id
30023
+ join ${schema.sessions} root
30024
+ on root.workspace_id = source.workspace_id and root.id = source.root_session_id
30025
+ where version.workspace_id = ${workspaceId} and version.artifact_id = ${siteId}::uuid
30026
+ and version.revision = 1 and root.site_access_root_id is null
30027
+ limit 1`,
30028
+ );
30029
+ return home?.channelId ?? null;
30030
+ }
30031
+
29999
30032
  async function createSessionInTransaction(
30000
30033
  tx: SessionActivityDatabase,
30001
30034
  input: SessionCreateInput,
@@ -30285,6 +30318,10 @@ async function createSessionInTransaction(
30285
30318
  input.workspaceId,
30286
30319
  parseAcceptedMcpAccountBindings(input.mcpAccountBindings),
30287
30320
  );
30321
+ const channelId =
30322
+ input.channelId === undefined && !input.parentSessionId
30323
+ ? await siteHomeChannelId(tx as unknown as Database, input.workspaceId, sessionMetadata)
30324
+ : (input.channelId ?? null);
30288
30325
  try {
30289
30326
  const insertValues: typeof schema.sessions.$inferInsert = withLosslessContentWriteVersion(
30290
30327
  {
@@ -30321,7 +30358,7 @@ async function createSessionInTransaction(
30321
30358
  variableSetId,
30322
30359
  rigId: input.rigId ?? null,
30323
30360
  rigVersionId: input.rigVersionId ?? null,
30324
- channelId: input.channelId ?? null,
30361
+ channelId,
30325
30362
  firstPartyMcpPermissions: input.firstPartyMcpPermissions ?? null,
30326
30363
  firstPartyMcpTools: input.firstPartyMcpTools ?? [...DEFAULT_FIRST_PARTY_MCP_TOOLS],
30327
30364
  initialPersonalConnectionDelegations: input.personalConnectionDelegations ?? [],
@@ -32021,6 +32058,13 @@ export type SessionListFilterOptions = {
32021
32058
  siteAccessId?: string;
32022
32059
  /** Sessions created through this published Site, independent of project. */
32023
32060
  originSiteId?: string;
32061
+ /**
32062
+ * Ordinary discovery omits conversations that run inside a Site. `include`
32063
+ * lists them as ordinary rows (search, pins, branches); `group` keeps them
32064
+ * out of root rows but counts them in totals and returns `siteGroups`.
32065
+ * Presentation only: native ownership and Site RLS still decide access.
32066
+ */
32067
+ siteConversations?: "include" | "group";
32024
32068
  /** Exact workspace project; null selects unfiled sessions. */
32025
32069
  channelId?: string | null;
32026
32070
  /** Exact frozen creator identity. */
@@ -33037,12 +33081,33 @@ async function sessionListTotalsInScope(
33037
33081
  db: Database,
33038
33082
  workspaceId: string,
33039
33083
  options: ListSessionsForSubjectOptions,
33040
- ): Promise<SessionListTotals> {
33084
+ withSiteRows = false,
33085
+ ): Promise<SessionListTotals & { sites: SiteConversationGroup[]; sitesTruncated: boolean }> {
33041
33086
  const archiveStatus = options.archiveStatus ?? (options.archivedOnly ? "archived" : "active");
33087
+ // A grouped rail counts Site conversations in its project totals and gets
33088
+ // their Site rows here; its ordinary rows still omit them.
33089
+ const siteConversations = options.siteConversations ? "include" : undefined;
33090
+ // Sites this person opened join the rows only in the plain view: a root
33091
+ // filter (Mine, search, creator, dates, archived content, another person's
33092
+ // scope) or an agent caller lists only Sites its listed chats created.
33093
+ const withOpenedSites =
33094
+ withSiteRows &&
33095
+ !options.needsYouOnly &&
33096
+ !options.mineOnly &&
33097
+ !options.search?.trim() &&
33098
+ !options.contentArchivedOnly &&
33099
+ !options.scopeSubjectId &&
33100
+ !options.createdBy &&
33101
+ !options.updatedFrom &&
33102
+ !options.updatedBefore &&
33103
+ !options.createdFrom &&
33104
+ !options.createdBefore &&
33105
+ !options.authorizationScope?.agentAccessViewer;
33042
33106
  const rootFilters = and(
33043
33107
  eq(schema.sessions.workspaceId, workspaceId),
33044
33108
  ...sessionFilters({
33045
33109
  ...options,
33110
+ ...(siteConversations ? { siteConversations } : {}),
33046
33111
  parentSessionId: null,
33047
33112
  needsYouOnly: false,
33048
33113
  archiveStatus: "all",
@@ -33051,7 +33116,11 @@ async function sessionListTotalsInScope(
33051
33116
  ? []
33052
33117
  : [sql`coalesce(root_archive.archived, false) = ${archiveStatus === "archived"}`]),
33053
33118
  );
33054
- const rows = await rawRows<{ needsYouCount: number; groups: SessionListTotals["groups"] }>(
33119
+ const rows = await rawRows<{
33120
+ needsYouCount: number;
33121
+ groups: SessionListTotals["groups"];
33122
+ sites: SiteConversationGroup[] | null;
33123
+ }>(
33055
33124
  db,
33056
33125
  sql`
33057
33126
  -- Inline this subject's state so each lateral lookup can use the session
@@ -33072,6 +33141,11 @@ async function sessionListTotalsInScope(
33072
33141
  ${schema.sessions.activeTurnId} as active_turn_id,
33073
33142
  ${schema.sessions.inputWaitUntil} as input_wait_until,
33074
33143
  ${schema.sessions.inputWaitTurnId} as input_wait_turn_id,
33144
+ ${schema.sessions.siteId} as site_id,
33145
+ ${schema.sessions.siteAccessRootId} is not null as site_conversation,
33146
+ ${schema.sessions.updatedAt} as updated_at,
33147
+ ${schema.sessions.createdAt} as created_at,
33148
+ ${schema.sessions.metadata}->'_opengeniSiteOrigin'->>'title' as origin_title,
33075
33149
  ${sessionRelatedListScope(options.authorizationScope)} as related,
33076
33150
  coalesce(root_personal.archived, false) as archived,
33077
33151
  coalesce(root_personal.pinned, false) as pinned,
@@ -33142,6 +33216,9 @@ async function sessionListTotalsInScope(
33142
33216
  from roots root left join attention_roots attention on attention.root_id = root.id
33143
33217
  ), measured as (
33144
33218
  select nodes.*,
33219
+ root.site_conversation as root_site_conversation, root.site_id as root_site_id,
33220
+ root.updated_at as root_updated_at, root.created_at as root_created_at,
33221
+ root.origin_title as root_origin_title,
33145
33222
  cursor.last_meaningful_sequence > nodes.acknowledged_sequence as unread,
33146
33223
  nodes.actively_working as active_work,
33147
33224
  case when nodes.status = 'requires_action' then (
@@ -33198,21 +33275,150 @@ async function sessionListTotalsInScope(
33198
33275
  count(*) filter (where unread)::int as unread,
33199
33276
  count(*) filter (where active_work)::int as "activeWork"
33200
33277
  from measured group by channel_id
33278
+ ), site_conv as (
33279
+ -- Site conversations nest under their Site's row in their own project.
33280
+ -- Pinned ones live with pins.
33281
+ select root.*, root.channel_id as row_channel_id
33282
+ from roots root
33283
+ where ${
33284
+ withSiteRows
33285
+ ? sql`root.site_conversation and root.site_id is not null and not root.pinned
33286
+ ${
33287
+ options.needsYouOnly
33288
+ ? sql`and root.id in (select id from root_attention where needs_you)`
33289
+ : sql``
33290
+ }`
33291
+ : sql`false`
33292
+ }
33293
+ ), site_conv_totals as (
33294
+ select site_id, row_channel_id as channel_id, count(*)::int as conversations,
33295
+ max(updated_at) as updated_at, max(created_at) as created_at,
33296
+ (array_agg(origin_title order by updated_at desc, id desc)
33297
+ filter (where origin_title is not null))[1] as origin_title
33298
+ from site_conv group by site_id, row_channel_id
33299
+ ), site_home as (
33300
+ -- A Site sits in the project of the chat that created it (the tree of
33301
+ -- its first version's session), as far as this viewer can see that
33302
+ -- chat; new Site conversations are filed there too. A Site without
33303
+ -- conversations here is listed when this viewer lists its creating
33304
+ -- chat, or opened it. Candidates are the live Sites in this viewer's
33305
+ -- audience (one row each); revision 1 is a unique-index lookup.
33306
+ select artifact.id as site_id, creator.channel_id
33307
+ from ${schema.workspaceArtifacts} artifact
33308
+ join lateral (
33309
+ select version.source_session_id from ${schema.workspaceArtifactVersions} version
33310
+ where version.workspace_id = ${workspaceId} and version.artifact_id = artifact.id
33311
+ and version.revision = 1
33312
+ ) first_version on true
33313
+ left join ${schema.sessions} source
33314
+ on source.workspace_id = ${workspaceId} and source.id = first_version.source_session_id
33315
+ left join ${schema.sessions} creator
33316
+ on creator.workspace_id = ${workspaceId} and creator.id = source.root_session_id
33317
+ and creator.site_access_root_id is null
33318
+ where ${withSiteRows && !options.needsYouOnly ? sql`true` : sql`false`}
33319
+ and artifact.workspace_id = ${workspaceId} and artifact.status = 'active'
33320
+ and (artifact.visibility = 'workspace_shared'
33321
+ or artifact.owner_subject_id = ${options.subjectId})
33322
+ and artifact.id not in (select site_id from site_conv)
33323
+ and (exists (select 1 from roots listed where listed.id = creator.id)
33324
+ ${
33325
+ withOpenedSites
33326
+ ? sql`or exists (
33327
+ select 1 from ${schema.siteOpens} opened
33328
+ where opened.workspace_id = ${workspaceId}
33329
+ and opened.subject_id = ${options.subjectId} and opened.site_id = artifact.id)`
33330
+ : sql``
33331
+ })
33332
+ ), site_keys as (
33333
+ select * from site_conv_totals
33334
+ union all
33335
+ select site_id, channel_id, 0, null, null, null from site_home
33336
+ ), site_rows as (
33337
+ -- One row per Site and project. The title is the Site's current one when
33338
+ -- its audience includes this viewer (the catalog's rule; RLS only
33339
+ -- isolates the workspace), else the creation-time label. A row without
33340
+ -- conversations needs a live Site the viewer can see. Activity is the
33341
+ -- latest of its conversations, its own changes and this person opening
33342
+ -- it. One extra row reports truncation.
33343
+ select key.site_id as "siteId", key.channel_id as "channelId",
33344
+ coalesce(site.title, key.origin_title, '') as title,
33345
+ key.conversations,
33346
+ greatest(key.updated_at, site.updated_at, opened.opened_at) as "updatedAt",
33347
+ coalesce(key.created_at, site.created_at) as "createdAt"
33348
+ from site_keys key
33349
+ left join lateral (
33350
+ select artifact.title, artifact.status, artifact.created_at, artifact.updated_at
33351
+ from ${schema.workspaceArtifacts} artifact
33352
+ where artifact.workspace_id = ${workspaceId} and artifact.id = key.site_id
33353
+ and (artifact.visibility = 'workspace_shared'
33354
+ or artifact.owner_subject_id = ${options.subjectId})
33355
+ ) site on true
33356
+ left join lateral (
33357
+ select opened.opened_at from ${schema.siteOpens} opened
33358
+ where opened.workspace_id = ${workspaceId} and opened.subject_id = ${options.subjectId}
33359
+ and opened.site_id = key.site_id
33360
+ ) opened on true
33361
+ where (key.conversations > 0 or site.status = 'active')
33362
+ ${options.channelId !== undefined ? sql`and key.channel_id is not distinct from ${options.channelId}::uuid` : sql``}
33363
+ order by 5 desc, 1, 2
33364
+ limit ${SITE_CONVERSATION_GROUP_LIMIT + 1}
33365
+ ), site_status as (
33366
+ -- Status counts follow project totals: active, unpinned trees only.
33367
+ select conv.site_id, conv.row_channel_id, count(*)::int as total,
33368
+ count(*) filter (where node.status = 'requires_action')::int as attention,
33369
+ min(node.attention_since) as "attentionSince",
33370
+ count(*) filter (where node.status = 'failed' and node.unread)::int as failed,
33371
+ count(*) filter (where node.pause_revision is null and
33372
+ (node.status in ('running', 'recovering') or node.input_wait))::int as active,
33373
+ count(*) filter (where node.pause_revision is null and
33374
+ node.status in ('queued', 'waiting_capacity'))::int as queued,
33375
+ count(*) filter (where node.pause_revision is null and node.input_wait)::int as waiting,
33376
+ count(*) filter (where node.unread)::int as unread,
33377
+ count(*) filter (where node.active_work)::int as "activeWork"
33378
+ from measured node join site_conv conv on conv.id = node.root_id
33379
+ group by conv.site_id, conv.row_channel_id
33380
+ ), site_grouped as (
33381
+ select site_rows.*, coalesce(status.total, 0) as total,
33382
+ coalesce(status.attention, 0) as attention, status."attentionSince",
33383
+ coalesce(status.failed, 0) as failed, coalesce(status.active, 0) as active,
33384
+ coalesce(status.queued, 0) as queued, coalesce(status.waiting, 0) as waiting,
33385
+ coalesce(status.unread, 0) as unread, coalesce(status."activeWork", 0) as "activeWork"
33386
+ from site_rows left join site_status status
33387
+ on status.site_id = site_rows."siteId"
33388
+ and status.row_channel_id is not distinct from site_rows."channelId"
33201
33389
  )
33202
33390
  select (select count(*)::int from root_attention where needs_you) as "needsYouCount",
33203
- coalesce((select jsonb_agg(to_jsonb(grouped)) from grouped), '[]'::jsonb) as groups
33391
+ coalesce((select jsonb_agg(to_jsonb(grouped)) from grouped), '[]'::jsonb) as groups,
33392
+ coalesce((select jsonb_agg(to_jsonb(site_grouped)
33393
+ order by site_grouped."updatedAt" desc, site_grouped."siteId", site_grouped."channelId")
33394
+ from site_grouped), '[]'::jsonb) as sites
33204
33395
  `,
33205
33396
  );
33206
33397
  const result = rows[0];
33398
+ const iso = (value: string | null) => (value ? new Date(value).toISOString() : null);
33399
+ const sites = result?.sites ?? [];
33207
33400
  return {
33208
33401
  needsYouCount: Number(result?.needsYouCount ?? 0),
33209
33402
  groups: (result?.groups ?? []).map((group) => ({
33210
33403
  ...group,
33211
- attentionSince: group.attentionSince ? new Date(group.attentionSince).toISOString() : null,
33404
+ attentionSince: iso(group.attentionSince),
33212
33405
  })),
33406
+ sites: sites.slice(0, SITE_CONVERSATION_GROUP_LIMIT).map((site) => ({
33407
+ ...site,
33408
+ updatedAt: iso(site.updatedAt)!,
33409
+ createdAt: iso(site.createdAt)!,
33410
+ attentionSince: iso(site.attentionSince),
33411
+ })),
33412
+ sitesTruncated: sites.length > SITE_CONVERSATION_GROUP_LIMIT,
33213
33413
  };
33214
33414
  }
33215
33415
 
33416
+ /**
33417
+ * Bound on Site rows per read. Rows sort by activity, so Sites nobody touched
33418
+ * for a long time fall past it; the Artifacts page lists every Site.
33419
+ */
33420
+ export const SITE_CONVERSATION_GROUP_LIMIT = 200;
33421
+
33216
33422
  function sessionFilters(
33217
33423
  options: Pick<
33218
33424
  ListSessionsForSubjectOptions,
@@ -33225,6 +33431,7 @@ function sessionFilters(
33225
33431
  | "channelId"
33226
33432
  | "originSiteId"
33227
33433
  | "siteAccessId"
33434
+ | "siteConversations"
33228
33435
  | "createdBy"
33229
33436
  | "updatedFrom"
33230
33437
  | "updatedBefore"
@@ -33258,9 +33465,14 @@ function sessionFilters(
33258
33465
  eq(schema.sessions.siteId, options.originSiteId),
33259
33466
  sql`${schema.sessions.siteAccessRootId} is not null`,
33260
33467
  );
33261
- } else {
33468
+ } else if (
33469
+ options.siteConversations !== "include" ||
33470
+ options.authorizationScope?.agentAccessViewer
33471
+ ) {
33262
33472
  // App-internal execution is not ordinary workspace conversation discovery.
33263
33473
  // This is presentation scope only: native ownership and Site RLS still apply.
33474
+ // The console rail opts in explicitly (`include` or `group`); agent
33475
+ // callers keep their own-root bound so the option never widens discovery.
33264
33476
  const ownRoot = options.authorizationScope?.agentAccessViewer?.callerRootSessionId;
33265
33477
  filters.push(
33266
33478
  ownRoot
@@ -33511,6 +33723,7 @@ function sessionListFilterIdentity(options: SessionListFilterOptions): string {
33511
33723
  !hasChannel &&
33512
33724
  !options.originSiteId &&
33513
33725
  !options.siteAccessId &&
33726
+ options.siteConversations !== "include" &&
33514
33727
  !options.createdBy &&
33515
33728
  !options.updatedFrom &&
33516
33729
  !options.updatedBefore &&
@@ -33526,6 +33739,8 @@ function sessionListFilterIdentity(options: SessionListFilterOptions): string {
33526
33739
  return JSON.stringify([
33527
33740
  ...(options.siteAccessId ? [["siteAccess", options.siteAccessId]] : []),
33528
33741
  ...(options.originSiteId ? [["originSite", options.originSiteId]] : []),
33742
+ // `group` returns the same rows as the default; only `include` changes them.
33743
+ ...(options.siteConversations === "include" ? [["siteConversations", "include"]] : []),
33529
33744
  hasChannel ? ["channel", options.channelId] : null,
33530
33745
  options.createdBy ? ["creator", options.createdBy.kind, options.createdBy.subjectId] : null,
33531
33746
  options.updatedFrom ? ["updatedFrom", options.updatedFrom.toISOString()] : null,
@@ -33883,7 +34098,27 @@ export async function listVisibleSessionInvalidationIds(
33883
34098
  sessionIds: string[],
33884
34099
  authority: SessionListReadAuthority,
33885
34100
  ): Promise<string[]> {
33886
- if (!sessionIds.length) return [];
34101
+ return (await listVisibleSessionInvalidationHints(db, workspaceId, sessionIds, authority))
34102
+ .sessionIds;
34103
+ }
34104
+
34105
+ /**
34106
+ * Ordinary IDs plus, separately, the viewer's own visible Site conversations.
34107
+ * A rail with Site rows refreshes their summaries for the latter without
34108
+ * treating every unknown Site conversation as a full list invalidation.
34109
+ */
34110
+ export async function listVisibleSessionInvalidationHints(
34111
+ db: Database,
34112
+ workspaceId: string,
34113
+ sessionIds: string[],
34114
+ authority: SessionListReadAuthority,
34115
+ ): Promise<{ sessionIds: string[]; siteSessionIds: string[] }> {
34116
+ if (!sessionIds.length) return { sessionIds: [], siteSessionIds: [] };
34117
+ // Exactly the rows ordinary discovery omits (an agent keeps its own root).
34118
+ const ownRoot = authority.authorizationScope?.agentAccessViewer?.callerRootSessionId;
34119
+ const siteOnly = ownRoot
34120
+ ? sql<boolean>`(${schema.sessions.siteAccessRootId} is not null and ${schema.sessions.rootSessionId} is distinct from ${ownRoot}::uuid)`
34121
+ : sql<boolean>`${schema.sessions.siteAccessRootId} is not null`;
33887
34122
  return await withWorkspaceSubjectRls(
33888
34123
  db,
33889
34124
  workspaceId,
@@ -33891,16 +34126,24 @@ export async function listVisibleSessionInvalidationIds(
33891
34126
  async (tx) => {
33892
34127
  await requireSessionListReadAuthorityInScope(tx, workspaceId, authority);
33893
34128
  const rows = await tx
33894
- .select({ id: schema.sessions.id })
34129
+ .select({
34130
+ id: schema.sessions.id,
34131
+ site: siteOnly,
34132
+ })
33895
34133
  .from(schema.sessions)
33896
34134
  .where(
33897
34135
  and(
33898
34136
  eq(schema.sessions.workspaceId, workspaceId),
33899
34137
  inArray(schema.sessions.id, sessionIds),
33900
- ...sessionFilters({ ...authority, archiveStatus: "all" }),
34138
+ // Hints carry no content. Site conversations use the same viewer
34139
+ // authorization (and agent bound) as `siteConversations=include`.
34140
+ ...sessionFilters({ ...authority, archiveStatus: "all", siteConversations: "include" }),
33901
34141
  ),
33902
34142
  );
33903
- return rows.map((row) => row.id);
34143
+ return {
34144
+ sessionIds: rows.filter((row) => !row.site).map((row) => row.id),
34145
+ siteSessionIds: rows.filter((row) => row.site).map((row) => row.id),
34146
+ };
33904
34147
  },
33905
34148
  { isolationLevel: "read committed" },
33906
34149
  );
@@ -33931,16 +34174,43 @@ async function readSessionListForSubject(
33931
34174
  }
33932
34175
  if (options.includeTotals && options.parentSessionId !== null && !options.pinsOnly)
33933
34176
  throw new SessionListCursorError("Attention totals require root or global pin pages");
33934
- const totals = options.includeTotals
33935
- ? await sessionListTotalsInScope(
33936
- tx,
33937
- workspaceId,
33938
- options.pinsOnly
33939
- ? { ...options, archiveStatus: "active", parentSessionId: null }
33940
- : options,
33941
- )
33942
- : undefined;
34177
+ const groupSites = options.siteConversations === "group";
34178
+ if (
34179
+ groupSites &&
34180
+ (options.parentSessionId !== null ||
34181
+ options.pinsOnly ||
34182
+ archiveMode === "archived" ||
34183
+ options.originSiteId ||
34184
+ options.siteAccessId)
34185
+ )
34186
+ throw new SessionListCursorError("Site rows require an ordinary, non-archived root page");
34187
+ // Site rows are a complete section like pins: read once, with page one.
34188
+ const siteGroupsRequested = groupSites && !options.cursor;
34189
+ const completeTotals =
34190
+ options.includeTotals || siteGroupsRequested
34191
+ ? await sessionListTotalsInScope(
34192
+ tx,
34193
+ workspaceId,
34194
+ options.pinsOnly
34195
+ ? { ...options, archiveStatus: "active", parentSessionId: null }
34196
+ : options,
34197
+ siteGroupsRequested,
34198
+ )
34199
+ : undefined;
34200
+ const totals =
34201
+ options.includeTotals && completeTotals
34202
+ ? { needsYouCount: completeTotals.needsYouCount, groups: completeTotals.groups }
34203
+ : undefined;
34204
+ const siteGroups = siteGroupsRequested ? (completeTotals?.sites ?? []) : undefined;
34205
+ const siteGroupsTruncated = siteGroupsRequested && completeTotals?.sitesTruncated === true;
33943
34206
  const filters = [eq(schema.sessions.workspaceId, workspaceId), ...sessionFilters(options)];
34207
+ // A pinned Site conversation is a personal shortcut like any other pin.
34208
+ const pinnedFilters = groupSites
34209
+ ? [
34210
+ eq(schema.sessions.workspaceId, workspaceId),
34211
+ ...sessionFilters({ ...options, siteConversations: "include" }),
34212
+ ]
34213
+ : filters;
33944
34214
  const ordinaryPinFilter =
33945
34215
  archiveMode === "archived"
33946
34216
  ? sql`true`
@@ -34270,7 +34540,7 @@ async function readSessionListForSubject(
34270
34540
  eq(schema.sessionPins.workspaceId, workspaceId),
34271
34541
  eq(schema.sessionPins.subjectId, options.subjectId),
34272
34542
  eq(schema.sessionPins.pinned, true),
34273
- ...filters,
34543
+ ...pinnedFilters,
34274
34544
  ),
34275
34545
  )
34276
34546
  .orderBy(desc(schema.sessionPins.pinnedAt), desc(schema.sessions.id))
@@ -34403,6 +34673,9 @@ async function readSessionListForSubject(
34403
34673
  ...(options.needsYouOnly ? { needsYouOnly: true as const } : {}),
34404
34674
  ...(options.contentArchivedOnly ? { contentArchivedOnly: true as const } : {}),
34405
34675
  ...(options.mineOnly ? { mineOnly: true as const } : {}),
34676
+ ...(options.siteConversations ? { siteConversations: options.siteConversations } : {}),
34677
+ ...(siteGroups ? { siteGroups } : {}),
34678
+ ...(siteGroupsTruncated ? { siteGroupsTruncated: true } : {}),
34406
34679
  pinned: pinnedRows.map((row) => sessionListEntry(mapListSession(row))),
34407
34680
  pinnedTruncated,
34408
34681
  sessions: pageRows.map((row) => sessionListEntry(mapListSession(row))),
@@ -34415,6 +34688,9 @@ async function readSessionListForSubject(
34415
34688
  ...(options.needsYouOnly ? { needsYouOnly: true as const } : {}),
34416
34689
  ...(options.contentArchivedOnly ? { contentArchivedOnly: true as const } : {}),
34417
34690
  ...(options.mineOnly ? { mineOnly: true as const } : {}),
34691
+ ...(options.siteConversations ? { siteConversations: options.siteConversations } : {}),
34692
+ ...(siteGroups ? { siteGroups } : {}),
34693
+ ...(siteGroupsTruncated ? { siteGroupsTruncated: true } : {}),
34418
34694
  pinned: pinnedRows.map(mapListSession),
34419
34695
  pinnedTruncated,
34420
34696
  sessions: pageRows.map(mapListSession),
@@ -40878,7 +41154,7 @@ const DURABLE_TOOL_CALL_SCAN_PAGE = 32;
40878
41154
  * calls still read the whole turn before staying pending.
40879
41155
  */
40880
41156
  async function durableToolCallPairIdsTx(
40881
- tx: Pick<Database, "select">,
41157
+ tx: Pick<Database, "execute">,
40882
41158
  scope: { workspaceId: string; sessionId: string; turnId: string },
40883
41159
  pending: Array<{ id: string; callId: string; callType: string }>,
40884
41160
  ): Promise<string[]> {
@@ -40892,32 +41168,16 @@ async function durableToolCallPairIdsTx(
40892
41168
  const durable: string[] = [];
40893
41169
  let before: number | null = null;
40894
41170
  while (unproven.size > 0) {
40895
- const page = await tx
40896
- .select({
40897
- position: schema.sessionHistoryItems.position,
40898
- item: schema.sessionHistoryItems.item,
40899
- itemCodecVersion: schema.sessionHistoryItems.itemCodecVersion,
40900
- })
40901
- .from(schema.sessionHistoryItems)
40902
- .where(
40903
- and(
40904
- eq(schema.sessionHistoryItems.workspaceId, scope.workspaceId),
40905
- eq(schema.sessionHistoryItems.sessionId, scope.sessionId),
40906
- eq(schema.sessionHistoryItems.turnId, scope.turnId),
40907
- before === null ? undefined : lt(schema.sessionHistoryItems.position, before),
40908
- ),
40909
- )
40910
- .orderBy(desc(schema.sessionHistoryItems.position))
40911
- .limit(DURABLE_TOOL_CALL_SCAN_PAGE);
41171
+ const page = await readSessionHistoryToolIdentityPage(
41172
+ tx,
41173
+ scope,
41174
+ before,
41175
+ DURABLE_TOOL_CALL_SCAN_PAGE,
41176
+ );
40912
41177
  // Positions are unique per session, so each row is strictly older than
40913
41178
  // every row already seen.
40914
41179
  for (const row of page) {
40915
- const item = fromPostgresLosslessJson(row.item, row.itemCodecVersion) as Record<
40916
- string,
40917
- unknown
40918
- >;
40919
- const callId = historyCallId(item);
40920
- const type = historyItemType(item);
41180
+ const { callId, type } = row;
40921
41181
  if (callId === null || type === null) continue;
40922
41182
  const call = unproven.get(callId);
40923
41183
  if (!call) continue;
@@ -1974,6 +1974,7 @@ export const FORCE_RLS_TABLES = [
1974
1974
  "site_hostname_holds",
1975
1975
  "site_hostnames",
1976
1976
  "site_limit_profiles",
1977
+ "site_opens",
1977
1978
  "site_platform_brake",
1978
1979
  "site_platform_storage",
1979
1980
  "site_platform_totals",
@@ -2276,6 +2277,7 @@ export const RUNTIME_FULL_DML_TABLES = [
2276
2277
  "site_data_workspace_validation_windows",
2277
2278
  "site_hostnames",
2278
2279
  "site_limit_profiles",
2280
+ "site_opens",
2279
2281
  "site_platform_brake",
2280
2282
  "site_platform_storage",
2281
2283
  "site_platform_totals",
package/src/schema.ts CHANGED
@@ -316,6 +316,30 @@ export const siteSessionAccess = pgTable(
316
316
  ],
317
317
  );
318
318
 
319
+ /** When each person last opened a Site (migration 0908); personal ordering only. */
320
+ export const siteOpens = pgTable(
321
+ "site_opens",
322
+ {
323
+ accountId: uuid("account_id").notNull(),
324
+ workspaceId: uuid("workspace_id").notNull(),
325
+ subjectId: text("subject_id").notNull(),
326
+ siteId: uuid("site_id").notNull(),
327
+ openedAt: timestamp("opened_at", { withTimezone: true }).notNull().defaultNow(),
328
+ },
329
+ (table) => [
330
+ primaryKey({ columns: [table.workspaceId, table.subjectId, table.siteId] }),
331
+ foreignKey({
332
+ columns: [table.workspaceId, table.accountId],
333
+ foreignColumns: [workspaces.id, workspaces.accountId],
334
+ }).onDelete("cascade"),
335
+ foreignKey({
336
+ columns: [table.workspaceId, table.siteId],
337
+ foreignColumns: [workspaceArtifacts.workspaceId, workspaceArtifacts.id],
338
+ }).onDelete("cascade"),
339
+ index("site_opens_site_idx").on(table.workspaceId, table.siteId),
340
+ ],
341
+ );
342
+
319
343
  export const workspaceArtifactUploads = pgTable(
320
344
  "workspace_artifact_uploads",
321
345
  {