@opengeni/api-router 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.
@@ -5,13 +5,23 @@ export type SessionListHint = {
5
5
  available: boolean;
6
6
  /** null means reconcile all loaded scopes, including formerly visible rows. */
7
7
  sessionIds: string[] | null;
8
+ /**
9
+ * The viewer's changed Site conversations, which ordinary lists omit. Only
10
+ * a surface that shows them (the rail's Site rows) needs to act on these.
11
+ */
12
+ siteSessionIds?: string[];
13
+ };
14
+ /** Authorized hint IDs; a plain array has no Site conversations. */
15
+ export type SessionListHintIds = string[] | {
16
+ sessionIds: string[];
17
+ siteSessionIds: string[];
8
18
  };
9
19
  /** Transient commit hints; only the subsequent authorized list read is truth. */
10
20
  export declare function subscribeSessionListHints(options: {
11
21
  source: SessionListChangeSource;
12
22
  workspaceId: string;
13
23
  subjectId: string;
14
- authorizeIds: (ids: string[]) => Promise<string[]>;
24
+ authorizeIds: (ids: string[]) => Promise<SessionListHintIds>;
15
25
  write: (hint: SessionListHint) => Promise<unknown>;
16
26
  fail: (error: unknown) => void;
17
27
  intervalMs: number;
@@ -2,6 +2,7 @@ import { type SessionEvent } from "@opengeni/contracts";
2
2
  import { type Database, type SessionListChangeSource } from "@opengeni/db";
3
3
  import { type EventBus } from "@opengeni/events";
4
4
  import type { Observability } from "@opengeni/observability";
5
+ import { type SessionListHintIds } from "./session-list-hints.js";
5
6
  export declare const SSE_QUEUED_FRAME_MAX_COUNT = 1;
6
7
  export declare const SSE_WRITE_STALL_TIMEOUT_MS = 30000;
7
8
  export declare const SSE_HEARTBEAT_INTERVAL_MS = 15000;
@@ -78,7 +79,7 @@ export type WorkspaceInteractionSseOptions = SseDeliveryOptions & {
78
79
  sessionLists?: {
79
80
  source: SessionListChangeSource;
80
81
  subjectId: string;
81
- authorizeIds: (ids: string[]) => Promise<string[]>;
82
+ authorizeIds: (ids: string[]) => Promise<SessionListHintIds>;
82
83
  } | undefined;
83
84
  };
84
85
  /**
package/dist/index.js CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  startSlackInteractionPump,
21
21
  startTemporalScheduleCleanupPump,
22
22
  withAccessGrantSessionRlsContext
23
- } from "./chunk-3KRHXJPM.js";
23
+ } from "./chunk-LXUAR3HS.js";
24
24
  import "./chunk-OGQDXVXY.js";
25
25
 
26
26
  // src/index.ts
@@ -121,6 +121,7 @@ export declare const ClientSignalReport: z.ZodDiscriminatedUnion<[z.ZodObject<{
121
121
  settings: "settings";
122
122
  "setup-account": "setup-account";
123
123
  "site-auth": "site-auth";
124
+ sites: "sites";
124
125
  state: "state";
125
126
  "variable-sets": "variable-sets";
126
127
  }>;
@@ -19,6 +19,7 @@ export declare function sessionTenancyHttpError(error: unknown): Error;
19
19
  export declare function sessionListQuery(query: Record<string, string>, allowCursor?: boolean): {
20
20
  originSiteId: string | undefined;
21
21
  siteAccessId: string | undefined;
22
+ siteConversations: "include" | "group" | undefined;
22
23
  limit: string | undefined;
23
24
  parentSessionId: string | null | undefined;
24
25
  cursor: ReturnType<typeof decodeSessionListCursor> | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/api-router",
3
- "version": "4.4.1",
3
+ "version": "4.5.0",
4
4
  "description": "Opengeni HTTP surface: the Hono adapter/router (createApp), routes, MCP HTTP transport, and HTTP access adapters over @opengeni/core. An engine-distribution surface — its runtime closure includes engine-internal packages.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -45,29 +45,29 @@
45
45
  "@keeper-security/secrets-manager-core": "17.6.0",
46
46
  "@llamaindex/liteparse": "2.14.2",
47
47
  "@modelcontextprotocol/sdk": "^1.29.0",
48
- "@opengeni/agent-proto": "4.4.1",
49
- "@opengeni/artifact-tool": "4.4.1",
50
- "@opengeni/capabilities": "4.4.1",
51
- "@opengeni/codemode": "4.4.1",
52
- "@opengeni/codex": "4.4.1",
53
- "@opengeni/config": "4.4.1",
54
- "@opengeni/contracts": "4.4.1",
55
- "@opengeni/core": "4.4.1",
56
- "@opengeni/db": "4.4.1",
57
- "@opengeni/documents": "4.4.1",
58
- "@opengeni/events": "4.4.1",
59
- "@opengeni/github": "4.4.1",
60
- "@opengeni/interaction": "4.4.1",
61
- "@opengeni/network": "4.4.1",
62
- "@opengeni/observability": "4.4.1",
63
- "@opengeni/runtime": "4.4.1",
64
- "@opengeni/storage": "4.4.1",
65
- "@opengeni/tool-gateway": "4.4.1",
66
- "@opengeni/vaults": "4.4.1",
67
- "@opengeni/vaults-keeper": "4.4.1",
68
- "@opengeni/vaults-onepassword": "4.4.1",
69
- "@opengeni/vaults-protonpass": "4.4.1",
70
- "@opengeni/xai-subscription": "4.4.1",
48
+ "@opengeni/agent-proto": "4.5.0",
49
+ "@opengeni/artifact-tool": "4.5.0",
50
+ "@opengeni/capabilities": "4.5.0",
51
+ "@opengeni/codemode": "4.5.0",
52
+ "@opengeni/codex": "4.5.0",
53
+ "@opengeni/config": "4.5.0",
54
+ "@opengeni/contracts": "4.5.0",
55
+ "@opengeni/core": "4.5.0",
56
+ "@opengeni/db": "4.5.0",
57
+ "@opengeni/documents": "4.5.0",
58
+ "@opengeni/events": "4.5.0",
59
+ "@opengeni/github": "4.5.0",
60
+ "@opengeni/interaction": "4.5.0",
61
+ "@opengeni/network": "4.5.0",
62
+ "@opengeni/observability": "4.5.0",
63
+ "@opengeni/runtime": "4.5.0",
64
+ "@opengeni/storage": "4.5.0",
65
+ "@opengeni/tool-gateway": "4.5.0",
66
+ "@opengeni/vaults": "4.5.0",
67
+ "@opengeni/vaults-keeper": "4.5.0",
68
+ "@opengeni/vaults-onepassword": "4.5.0",
69
+ "@opengeni/vaults-protonpass": "4.5.0",
70
+ "@opengeni/xai-subscription": "4.5.0",
71
71
  "@temporalio/client": "^1.17.0",
72
72
  "ajv": "^8.20.0",
73
73
  "better-auth": "^1.6.14",
package/src/app.ts CHANGED
@@ -193,6 +193,7 @@ import {
193
193
  registerProductUsageMetricBaselines,
194
194
  stampPersonalApiKeyRequest,
195
195
  verifiedDelegatedHumanAuthorizationForRequest,
196
+ browserExtension,
196
197
  } from "@opengeni/core";
197
198
  import { createManagedAuthNewSignupsGate } from "./auth/new-signups-gate";
198
199
  import {
@@ -1516,6 +1517,7 @@ export function createAppComposition(deps: AppDependencies): {
1516
1517
  deploymentRevision: deps.settings.deploymentRevision,
1517
1518
  claudeSubscriptionEnabled: deps.settings.claudeSubscriptionEnabled,
1518
1519
  connectedMachines: deps.settings.sandboxSelfhostedEnabled,
1520
+ browserExtension: browserExtension(deps.settings),
1519
1521
  apiContractRevision: OPENGENI_API_CONTRACT_REVISION,
1520
1522
  ...(deps.settings.serverVersion ? { serverVersion: deps.settings.serverVersion } : {}),
1521
1523
  defaultModel,
@@ -6,14 +6,22 @@ export type SessionListHint = {
6
6
  available: boolean;
7
7
  /** null means reconcile all loaded scopes, including formerly visible rows. */
8
8
  sessionIds: string[] | null;
9
+ /**
10
+ * The viewer's changed Site conversations, which ordinary lists omit. Only
11
+ * a surface that shows them (the rail's Site rows) needs to act on these.
12
+ */
13
+ siteSessionIds?: string[];
9
14
  };
10
15
 
16
+ /** Authorized hint IDs; a plain array has no Site conversations. */
17
+ export type SessionListHintIds = string[] | { sessionIds: string[]; siteSessionIds: string[] };
18
+
11
19
  /** Transient commit hints; only the subsequent authorized list read is truth. */
12
20
  export function subscribeSessionListHints(options: {
13
21
  source: SessionListChangeSource;
14
22
  workspaceId: string;
15
23
  subjectId: string;
16
- authorizeIds: (ids: string[]) => Promise<string[]>;
24
+ authorizeIds: (ids: string[]) => Promise<SessionListHintIds>;
17
25
  write: (hint: SessionListHint) => Promise<unknown>;
18
26
  fail: (error: unknown) => void;
19
27
  intervalMs: number;
@@ -24,12 +32,13 @@ export function subscribeSessionListHints(options: {
24
32
  let all = false;
25
33
  const ids = new Set<string>();
26
34
  let tail = Promise.resolve();
27
- const emit = (available: boolean, sessionIds: string[] | null) =>
35
+ const emit = (available: boolean, sessionIds: string[] | null, siteSessionIds: string[] = []) =>
28
36
  options.write({
29
37
  type: "workspace.sessions.changed",
30
38
  workspaceId: options.workspaceId,
31
39
  available,
32
40
  sessionIds,
41
+ ...(siteSessionIds.length ? { siteSessionIds } : {}),
33
42
  });
34
43
  const enqueue = (action: () => Promise<unknown>) => {
35
44
  tail = tail
@@ -73,10 +82,14 @@ export function subscribeSessionListHints(options: {
73
82
  ids.clear();
74
83
  enqueue(async () => {
75
84
  if (observedGeneration !== generation || !connected) return;
76
- const visibleIds = reconcileAll ? null : await options.authorizeIds(pendingIds);
85
+ const authorized = reconcileAll ? null : await options.authorizeIds(pendingIds);
77
86
  if (observedGeneration !== generation || !connected) return;
78
- if (visibleIds !== null && !visibleIds.length) return;
79
- await emit(true, visibleIds);
87
+ if (authorized === null) return await emit(true, null);
88
+ const visible = Array.isArray(authorized)
89
+ ? { sessionIds: authorized, siteSessionIds: [] }
90
+ : authorized;
91
+ if (!visible.sessionIds.length && !visible.siteSessionIds.length) return;
92
+ await emit(true, visible.sessionIds, visible.siteSessionIds);
80
93
  });
81
94
  }, options.intervalMs);
82
95
  return () => {
package/src/http/sse.ts CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  } from "@opengeni/events";
23
23
  import type { Observability } from "@opengeni/observability";
24
24
  import { MANAGED_AUTH_ACTOR_EPOCH_HEADER } from "@opengeni/core/managed-auth-session-sets";
25
- import { subscribeSessionListHints } from "./session-list-hints";
25
+ import { subscribeSessionListHints, type SessionListHintIds } from "./session-list-hints";
26
26
 
27
27
  const SESSION_REPLAY_PAGE_SIZE = 100;
28
28
  const SESSION_REPLAY_PAGE_MAX_BYTES = 8 * 1024 * 1024;
@@ -744,7 +744,7 @@ export type WorkspaceInteractionSseOptions = SseDeliveryOptions & {
744
744
  | {
745
745
  source: SessionListChangeSource;
746
746
  subjectId: string;
747
- authorizeIds: (ids: string[]) => Promise<string[]>;
747
+ authorizeIds: (ids: string[]) => Promise<SessionListHintIds>;
748
748
  }
749
749
  | undefined;
750
750
  };
@@ -6407,6 +6407,15 @@ export const ACTION_CATALOG: readonly ActionCatalogEntry[] = [
6407
6407
  "PersonalGitHubOAuthStartResponse"
6408
6408
  ]
6409
6409
  },
6410
+ {
6411
+ "id": "recordSiteOpened",
6412
+ "method": "POST",
6413
+ "path": "/v1/workspaces/:workspaceId/published-artifacts/:artifactId/opened",
6414
+ "request": [],
6415
+ "response": [
6416
+ "SiteOpenedResponse"
6417
+ ]
6418
+ },
6410
6419
  {
6411
6420
  "id": "recoverSandbox",
6412
6421
  "method": "POST",
package/src/mcp/server.ts CHANGED
@@ -210,9 +210,9 @@ import {
210
210
  buildCapabilityCatalog,
211
211
  CONNECTED_MACHINE_CAPABILITY_ID,
212
212
  EXCEL_ADDIN_CAPABILITY_ID,
213
+ browserExtension,
213
214
  nativeConnectionCapabilityRecommendations,
214
215
  userSetupCardPayload,
215
- OPENGENI_BROWSER_EXTENSION_URL,
216
216
  requireLiveAgentAttemptAuthorization,
217
217
  requireSessionAuthorization,
218
218
  requireSessionAuthorizationListScope,
@@ -2297,7 +2297,7 @@ function registerSandboxFileArtifactTool(
2297
2297
  "sandbox_file_publish",
2298
2298
  {
2299
2299
  description:
2300
- "Publish one exact file from this session's /workspace into durable workspace storage. Use this before presenting a video, ZIP, CSV, JSON, Markdown, HTML, PDF, Office file, or other sandbox output as downloadable. Present the returned artifact.artifactId as [Open file](artifact:<artifactId>), or ![Preview](artifact:<artifactId>) for inline image/video/audio/PDF previews. Replace <artifactId> with the exact returned ID and use a descriptive label. Never expose a sandbox: URL as the durable result. The artifactId is also the file's workspace File ID, so tools that take workspace file IDs, such as a browser upload action, accept it. A new file gets this chat's visibility (Only me in an Only me chat, otherwise the workspace) unless you pass visibility; to change an existing file, such as a generated image, pass its artifactId and visibility instead of path. Check the result's access.whoCanOpen before sharing a link with anyone.",
2300
+ "Publish one file from this session's workspace so the person can open it. Use it for every file you hand over: document, spreadsheet, PDF, image, video, archive or data. Link the result as [Descriptive label](artifact:<artifactId>), or ![Descriptive label](artifact:<artifactId>) to preview an image, video, audio or PDF inline; never give a sandbox: link instead. The artifactId is also the file's workspace file ID, which tools such as a browser upload accept. A new file gets this chat's visibility unless you pass visibility. To change who can open an existing file, such as a generated image, pass its artifactId and visibility instead of path. Check access.whoCanOpen in the result before sharing the link with anyone else.",
2301
2301
  inputSchema: {
2302
2302
  path: z4.string().min(1).max(4_096).optional(),
2303
2303
  artifactId: z4.string().uuid().optional(),
@@ -4471,9 +4471,10 @@ function registerFleetTools(
4471
4471
  // Same live-attempt authority the other fleet tools resolve, without
4472
4472
  // requiring a session box: a chat with no sandbox can still connect one.
4473
4473
  await requireLiveAgentAttemptAuthorization(deps.db, grant, sessionId);
4474
- const item = nativeConnectionCapabilityRecommendations({ connectedMachines: true }).find(
4475
- (candidate) => candidate.id === CONNECTED_MACHINE_CAPABILITY_ID,
4476
- )!;
4474
+ const item = nativeConnectionCapabilityRecommendations({
4475
+ connectedMachines: true,
4476
+ browserExtension: browserExtension(deps.settings),
4477
+ }).find((candidate) => candidate.id === CONNECTED_MACHINE_CAPABILITY_ID)!;
4477
4478
  const setup = await capabilitySetupProjection(
4478
4479
  deps,
4479
4480
  grant.workspaceId,
@@ -4501,7 +4502,7 @@ function registerFleetTools(
4501
4502
  setup.status === "ready"
4502
4503
  ? `${setup.detail} The Connected Machine card lists them, so the person can choose one for this chat or connect another.`
4503
4504
  : "The Connected Machine card was posted. It shows the person what to do, so do not describe its buttons or steps; nothing is connected yet."
4504
- } Check sandboxes_list after they confirm. Chrome needs the browser extension (${OPENGENI_BROWSER_EXTENSION_URL}) on the connected machine.`,
4505
+ } Check sandboxes_list after they confirm. Chrome needs the browser extension (${browserExtension(deps.settings).installUrl}) on the connected machine.`,
4505
4506
  });
4506
4507
  }
4507
4508
  return json(
@@ -4531,7 +4532,7 @@ function registerConnectedMachineTools(
4531
4532
  {
4532
4533
  description:
4533
4534
  "Create a single-use Connected Machine enrollment token for this workspace (expires in one hour) using existing enrollments:manage authority. Returns the token, expiry and Unix/PowerShell install commands. Use it when you can run commands on the target machine yourself (run_on, SSH, a VM you control): run the matching command there, then verify the machine with sandboxes_list. One token connects one machine; mint another for each additional machine (the same machine may rerun its command). When the person must run it on their own computer, post the Connected Machine card instead (capability_authorization_request with api:connected-machine), which mints the command in their browser; do not paste a token into the chat. Screen control is optional and defaults off; to turn it on for a machine that is already connected, use connected_machine_enable_screen_control instead of reconnecting. The token grants whole-machine access on enrollment; do not put it in public code or unrelated logs. Existing machine connections are preserved. For Chrome, the person installs the Opengeni Browser extension (" +
4534
- OPENGENI_BROWSER_EXTENSION_URL +
4535
+ browserExtension(deps.settings).installUrl +
4535
4536
  ") after the machine is connected.",
4536
4537
  inputSchema: { allowScreenControl: z4.boolean().optional() },
4537
4538
  },
@@ -4563,7 +4564,7 @@ function registerConnectedMachineTools(
4563
4564
  {
4564
4565
  description:
4565
4566
  "Turn screen control on for a machine that is already connected, in place, so agents can see and use its screen (computer and attached-Chrome tools). `target` is a sandboxes_list id or an enrollment id. Needs no human click, token, terminal or reconnect: the platform records the consent on that machine and its agent refreshes its own credentials over the live connection. status=active means it is on now. status=pending means it is allowed and turns on by itself: reason=offline when the machine next connects; reason=agent_update_required after its agent is updated (update it with the machine update action, POST /v1/workspaces/{workspaceId}/machines/{enrollmentId}/update); reason=renewal_failed: call again shortly; reason=reconnect_required: the machine's connection predates in-place changes, so rerun the connect command on it (connected_machine_enroll_token with allowScreenControl=true). On a Mac the OS must also allow Screen Recording, Accessibility and Input Monitoring for the app that runs the agent (the name macOS lists in those panes): open each pane on the Mac with run_on exec `open 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'` (Privacy_Accessibility, Privacy_ListenEvent; also the privacy-settings machine action) and ask the person to switch that app on there, choosing Quit & Reopen if macOS asks; the machine list reports runtime.macPermissions. Chrome additionally needs the Opengeni Browser extension (" +
4566
- OPENGENI_BROWSER_EXTENSION_URL +
4567
+ browserExtension(deps.settings).installUrl +
4567
4568
  ") on that machine.",
4568
4569
  inputSchema: { target: z4.string().min(1) },
4569
4570
  },
@@ -6720,6 +6721,7 @@ function registerCapabilityDiscoveryTools(
6720
6721
  ...current.items,
6721
6722
  ...nativeConnectionCapabilityRecommendations({
6722
6723
  connectedMachines: deps.settings.sandboxSelfhostedEnabled,
6724
+ browserExtension: browserExtension(deps.settings),
6723
6725
  }),
6724
6726
  ];
6725
6727
  const ranked = searchCapabilityCatalogItems(candidates, query, limit ?? 8);
@@ -6793,6 +6795,7 @@ function registerCapabilityDiscoveryTools(
6793
6795
  ...current.items,
6794
6796
  ...nativeConnectionCapabilityRecommendations({
6795
6797
  connectedMachines: deps.settings.sandboxSelfhostedEnabled,
6798
+ browserExtension: browserExtension(deps.settings),
6796
6799
  }),
6797
6800
  ].find((candidate) => candidate.id === capabilityId);
6798
6801
  if (!item || !capabilityCatalogItemIsTrustedForExposure(item)) {
@@ -6832,7 +6835,7 @@ function registerCapabilityDiscoveryTools(
6832
6835
  capabilityId: item.id,
6833
6836
  status: "ready",
6834
6837
  eventId,
6835
- message: `${setup.detail} The Connected Machine card is in this chat: the person can choose a machine for this chat or connect another. Chrome needs the browser extension (${OPENGENI_BROWSER_EXTENSION_URL}) on a connected machine.`,
6838
+ message: `${setup.detail} The Connected Machine card is in this chat: the person can choose a machine for this chat or connect another. Chrome needs the browser extension (${browserExtension(deps.settings).installUrl}) on a connected machine.`,
6836
6839
  });
6837
6840
  }
6838
6841
  if (githubCardWhenReady) {
@@ -6126,6 +6126,26 @@ export function parseBrowserScreenshotOptions(
6126
6126
  export function browserTabDiagnosisError(error: unknown): unknown {
6127
6127
  if (!(error instanceof BrowserControlRequestError)) return error;
6128
6128
  const code = error.error.code;
6129
+ // A controller that says a resource is gone for good (for example, the
6130
+ // person's Chrome profile reconnected and this session no longer controls
6131
+ // it) answers 503 but not retryable. As a 503 it would reach the agent as
6132
+ // "temporarily unavailable, retry", and every retry fails the same way, so
6133
+ // keep the controller's guidance and present it as the conflict it is.
6134
+ if (
6135
+ code === "resource_unavailable" &&
6136
+ error.status >= 500 &&
6137
+ !error.retryable &&
6138
+ !legacyInteractionCapacityFailure(error, "browser")
6139
+ ) {
6140
+ const failure = new ApiHttpError(409, {
6141
+ code: "conflict",
6142
+ message: error.error.message,
6143
+ retryable: false,
6144
+ outcomeUnknown: false,
6145
+ });
6146
+ failure.cause = error;
6147
+ return failure;
6148
+ }
6129
6149
  if (code !== "tab_not_responding" && code !== "tab_crashed") return error;
6130
6150
  const failure = new ApiHttpError(409, {
6131
6151
  code,
@@ -40,7 +40,7 @@ import {
40
40
  listInteractionInterventions,
41
41
  listNetworkRoutes,
42
42
  listSiteAuthConnections,
43
- listVisibleSessionInvalidationIds,
43
+ listVisibleSessionInvalidationHints,
44
44
  sessionListChangesFor,
45
45
  resolveInteractionIntervention,
46
46
  updateNetworkRoute,
@@ -113,7 +113,7 @@ export function registerInteractionResourceRoutes(app: Hono, deps: ApiRouteDeps)
113
113
  freshGrant,
114
114
  "http",
115
115
  );
116
- return await listVisibleSessionInvalidationIds(deps.db, workspaceId, ids, {
116
+ return await listVisibleSessionInvalidationHints(deps.db, workspaceId, ids, {
117
117
  subjectId: freshGrant.subjectId,
118
118
  ...(authorizationScope ? { authorizationScope } : {}),
119
119
  personalWorkspaceOwnerException:
@@ -1042,6 +1042,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1042
1042
  ...(query.channelId !== undefined ? { channelId: query.channelId } : {}),
1043
1043
  ...(query.originSiteId ? { originSiteId: query.originSiteId } : {}),
1044
1044
  ...(query.siteAccessId ? { siteAccessId: query.siteAccessId } : {}),
1045
+ ...(query.siteConversations ? { siteConversations: query.siteConversations } : {}),
1045
1046
  ...(query.createdBy ? { createdBy: query.createdBy } : {}),
1046
1047
  ...(query.updatedFrom ? { updatedFrom: query.updatedFrom } : {}),
1047
1048
  ...(query.updatedBefore ? { updatedBefore: query.updatedBefore } : {}),
@@ -5891,6 +5892,7 @@ export function sessionListQuery(
5891
5892
  ): {
5892
5893
  originSiteId: string | undefined;
5893
5894
  siteAccessId: string | undefined;
5895
+ siteConversations: "include" | "group" | undefined;
5894
5896
  limit: string | undefined;
5895
5897
  parentSessionId: string | null | undefined;
5896
5898
  cursor: ReturnType<typeof decodeSessionListCursor> | undefined;
@@ -5925,6 +5927,33 @@ export function sessionListQuery(
5925
5927
  });
5926
5928
  if (originSiteId !== undefined && !z.string().uuid().safeParse(originSiteId).success)
5927
5929
  throw new HTTPException(400, { message: "originSiteId must be a Site id" });
5930
+ const siteConversations = query.siteConversations;
5931
+ if (siteConversations !== undefined) {
5932
+ if (siteConversations !== "include" && siteConversations !== "group")
5933
+ throw new HTTPException(400, { message: 'siteConversations must be "include" or "group"' });
5934
+ if (!allowCursor)
5935
+ throw new HTTPException(400, { message: 'siteConversations requires view="page"' });
5936
+ // A Site's own history is already explicit; Site frames never reach this.
5937
+ if (originSiteId !== undefined || siteAccessId !== undefined)
5938
+ throw new HTTPException(400, {
5939
+ message: "siteConversations cannot be combined with a Site filter",
5940
+ });
5941
+ if (
5942
+ siteConversations === "group" &&
5943
+ (query.parentSessionId !== "null" || query.pinsOnly === "true")
5944
+ )
5945
+ throw new HTTPException(400, {
5946
+ message: 'siteConversations="group" requires a root page (parentSessionId=null)',
5947
+ });
5948
+ // Archived is a flat list: its Site conversations are ordinary rows.
5949
+ if (
5950
+ siteConversations === "group" &&
5951
+ (query.archiveStatus === "archived" || query.archivedOnly === "true")
5952
+ )
5953
+ throw new HTTPException(400, {
5954
+ message: 'siteConversations="group" cannot list archived sessions; use "include"',
5955
+ });
5956
+ }
5928
5957
  // "null" = roots only; a uuid = children of that session; anything else is
5929
5958
  // a client error (an unvalidated value would surface as a Postgres uuid cast
5930
5959
  // failure -> 500 rather than an honest 400).
@@ -6092,6 +6121,7 @@ export function sessionListQuery(
6092
6121
  limit: query.limit,
6093
6122
  originSiteId,
6094
6123
  siteAccessId,
6124
+ siteConversations,
6095
6125
  parentSessionId:
6096
6126
  parentSessionId === undefined
6097
6127
  ? undefined
@@ -5,6 +5,7 @@ import {
5
5
  PublishWorkspaceArtifactVersionRequest,
6
6
  RollbackWorkspaceArtifactRequest,
7
7
  SetWorkspaceArtifactStatusRequest,
8
+ SiteOpenedResponse,
8
9
  UpdateWorkspaceArtifactVisibilityRequest,
9
10
  UpdateWorkspaceArtifactVisibilityResponse,
10
11
  WORKSPACE_ARTIFACT_TITLE_MAX_CHARS,
@@ -24,6 +25,7 @@ import {
24
25
  resolveSiteAudienceChange,
25
26
  SessionAuthorizationDeniedError,
26
27
  SessionAuthorizationUnavailableError,
28
+ currentSiteSessionOrigin,
27
29
  siteViewerForGrant,
28
30
  type ApiRouteDeps,
29
31
  } from "@opengeni/core";
@@ -33,6 +35,7 @@ import {
33
35
  getWorkspaceArtifact,
34
36
  getWorkspaceArtifactContentRef,
35
37
  listWorkspaceArtifacts,
38
+ recordSiteOpened,
36
39
  nestedPostgresSqlState,
37
40
  publishWorkspaceArtifactVersion,
38
41
  rollbackWorkspaceArtifact,
@@ -232,6 +235,33 @@ export function registerWorkspaceArtifactRoutes(app: Hono, deps: ApiRouteDeps):
232
235
  }
233
236
  });
234
237
 
238
+ // Opening a Site counts as this person's activity on its sidebar row. It is
239
+ // personal, needs only read access, and a Site page cannot record it.
240
+ app.post(`${base}/:artifactId/opened`, async (context) => {
241
+ const workspaceId = context.req.param("workspaceId");
242
+ const grant = await requireAccessGrant(context, deps, workspaceId, "artifacts:read");
243
+ if (currentSiteSessionOrigin())
244
+ throw new HTTPException(403, { message: "A Site page cannot record opening a Site" });
245
+ const siteId = artifactId(context);
246
+ const viewer = await siteViewerForGrant(deps.db, grant);
247
+ if (!viewer.subjectId)
248
+ throw new HTTPException(403, { message: "A signed-in person is required" });
249
+ try {
250
+ return context.json(
251
+ SiteOpenedResponse.parse(
252
+ await recordSiteOpened(deps.db, {
253
+ workspaceId,
254
+ subjectId: viewer.subjectId,
255
+ viewer,
256
+ siteId,
257
+ }),
258
+ ),
259
+ );
260
+ } catch (error) {
261
+ return workspaceArtifactErrorResponse(context, error);
262
+ }
263
+ });
264
+
235
265
  app.get(base, async (context) => {
236
266
  const workspaceId = context.req.param("workspaceId");
237
267
  const grant = await requireAccessGrant(context, deps, workspaceId, "artifacts:read");