@opengeni/api-router 2.3.2-canary.2 → 2.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 (44) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/auth/managed-auth-attempt-context.d.ts +4 -0
  3. package/dist/auth/managed-auth-session-adapter.d.ts +4 -0
  4. package/dist/{chunk-IBV7Z6F4.js → chunk-QESX7HDK.js} +3645 -470
  5. package/dist/chunk-QESX7HDK.js.map +1 -0
  6. package/dist/fatal-process-boundary.d.ts +25 -0
  7. package/dist/http/sse.d.ts +2 -0
  8. package/dist/index.d.ts +5 -1
  9. package/dist/index.js +168 -6
  10. package/dist/index.js.map +1 -1
  11. package/dist/integrations/slack-interactions.d.ts +1 -1
  12. package/dist/mcp/receipts.d.ts +9 -0
  13. package/dist/mcp/server.d.ts +33 -0
  14. package/dist/organization-recovery-notifications.d.ts +41 -0
  15. package/dist/routes/managed-auth-session-sets.d.ts +19 -0
  16. package/dist/routes/organization-recovery.d.ts +19 -0
  17. package/dist/routes/sessions.d.ts +17 -5
  18. package/dist/routes/workspaces.d.ts +1 -0
  19. package/dist/work-discovery-observability.d.ts +33 -0
  20. package/package.json +18 -18
  21. package/src/app.ts +133 -1
  22. package/src/auth/managed-auth-attempt-context.ts +24 -0
  23. package/src/auth/managed-auth-session-adapter.ts +205 -0
  24. package/src/auth/managed-auth.ts +52 -2
  25. package/src/fatal-process-boundary.ts +231 -0
  26. package/src/http/sse.ts +7 -0
  27. package/src/index.ts +25 -5
  28. package/src/integrations/slack-interactions.ts +30 -17
  29. package/src/mcp/receipts.ts +34 -0
  30. package/src/mcp/server.ts +349 -68
  31. package/src/organization-recovery-notifications.ts +103 -0
  32. package/src/routes/canonical-human-identities.ts +29 -14
  33. package/src/routes/codex.ts +5 -1
  34. package/src/routes/environments.ts +23 -0
  35. package/src/routes/interaction-resources.ts +3 -0
  36. package/src/routes/managed-auth-session-sets.ts +994 -0
  37. package/src/routes/managed-onboarding.ts +2 -0
  38. package/src/routes/organization-memberships.ts +2 -0
  39. package/src/routes/organization-recovery.ts +325 -0
  40. package/src/routes/sessions.ts +401 -120
  41. package/src/routes/supergrok.ts +5 -1
  42. package/src/routes/workspaces.ts +23 -1
  43. package/src/work-discovery-observability.ts +121 -0
  44. package/dist/chunk-IBV7Z6F4.js.map +0 -1
@@ -66,12 +66,23 @@ import {
66
66
  UpdateSessionToolPolicyRequest,
67
67
  ViewerHeartbeatRequest,
68
68
  WORKSPACE_CONTROL_ACTOR_MAX_BYTES,
69
+ WORK_CLAIM_CANONICAL_KEY_MAX_BYTES,
70
+ WORK_CLAIM_DISCOVERY_LIMIT,
71
+ WORK_CLAIM_NAMESPACE_MAX_BYTES,
72
+ WORK_DISCOVERY_QUERY_MAX_CHARS,
73
+ WORK_DISCOVERY_RECENT_HOURS_MAX,
74
+ WorkClaimSubjectFilter as WorkClaimSubjectFilterSchema,
75
+ WorkClaimSubjectType,
76
+ normalizeWorkClaimCanonicalKey,
77
+ normalizeWorkClaimNamespace,
69
78
  workspaceControlUtf8Bytes,
70
79
  type AccessGrant,
71
80
  type AttachViewerResponse,
72
81
  type SandboxBackend,
73
82
  type LineageNode,
74
83
  type Session,
84
+ type SessionStatus,
85
+ type WorkClaimSubjectFilter,
75
86
  type SessionGoalRevision,
76
87
  type AgentTopologyPageResponse,
77
88
  type ErrorCode,
@@ -173,6 +184,7 @@ import {
173
184
  type SandboxRetainedProcess,
174
185
  type Database,
175
186
  type SessionDiscoveryCursor,
187
+ type SessionDiscoveryOrderBy,
176
188
  type SessionDiscoveryAncestor,
177
189
  } from "@opengeni/db";
178
190
  import {
@@ -211,6 +223,7 @@ import type { Context, Hono, MiddlewareHandler } from "hono";
211
223
  import { HTTPException } from "hono/http-exception";
212
224
  import type { ContentfulStatusCode } from "hono/utils/http-status";
213
225
  import {
226
+ getManagedAuthRequestActorEpoch,
214
227
  hasPermission,
215
228
  requireAccessGrant,
216
229
  requireAccessGrantAuthorization,
@@ -243,7 +256,6 @@ import {
243
256
  import { buildSessionCodexRealtimeBroker, CodexRealtimeBrokerError } from "../codex-realtime";
244
257
  import {
245
258
  acceptSessionUserMessage,
246
- acceptSessionUserMessageWithOutcome,
247
259
  controlHumanSessionWorkstream,
248
260
  createSessionForRequest,
249
261
  deleteHumanQueuePrompt,
@@ -259,6 +271,7 @@ import {
259
271
  SessionSpawnDeniedError,
260
272
  sessionSpawnDenialEnvelope,
261
273
  steerHumanQueuePrompt,
274
+ submitComposerDraftForRequest,
262
275
  updateSessionMcpApprovalPolicy,
263
276
  updateManagedHumanSessionVisibility,
264
277
  updateSessionToolPolicy,
@@ -279,6 +292,7 @@ import {
279
292
  } from "./workspace-capture";
280
293
  import { publishSandboxFileArtifact } from "../sandbox-file-artifacts";
281
294
  import { ApiHttpError } from "../http/api-error";
295
+ import { observeWorkDiscovery, summarizeWorkDiscoveryRows } from "../work-discovery-observability";
282
296
 
283
297
  type SessionRouteDeps = ApiRouteDeps & Pick<ViewerServices, "establishSandboxSession">;
284
298
 
@@ -699,71 +713,144 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
699
713
  throw sessionAuthorizationHttpError(error);
700
714
  }
701
715
  const query = agentTopologyQuery(c.req.query());
702
- const page = await listSessionDiscoverySummaries(db, workspaceId, {
703
- limit: query.limit,
704
- orderBy: "updatedAt",
705
- subjectId: grant.subjectId,
706
- ...(query.cursor ? { cursor: query.cursor } : {}),
707
- ...(query.search ? { search: query.search } : { parentSessionId: query.parentSessionId }),
708
- ...(authorizationScope ? { authorizationScope } : {}),
709
- });
710
- const ancestorPaths = query.search
711
- ? await listSessionDiscoveryAncestorPaths(
712
- db,
713
- workspaceId,
714
- page.sessions.map((session) => session.id),
715
- authorizationScope ?? undefined,
716
- )
717
- : new Map<string, SessionDiscoveryAncestor[]>();
718
- const sessions: AgentTopologyPageResponse["sessions"] = page.sessions.map((session) => {
719
- const blocker = session.effectiveControl.primaryBlocker;
720
- return {
721
- id: session.id,
722
- title: session.title,
723
- titleTruncated:
724
- session.titleOriginalChars !== null &&
725
- session.titleOriginalChars > Array.from(session.title ?? "").length,
726
- parentSessionId: session.parentSessionId,
727
- rootSessionId: session.rootSessionId,
728
- nestedAgentDepth: session.nestedAgentDepth,
729
- ancestorPath: (ancestorPaths.get(session.id) ?? []).map((ancestor) => ({
730
- id: ancestor.id,
731
- title: ancestor.title,
716
+ const startedAtMs = performance.now();
717
+ const mode = query.subject ? "subject" : query.query ? "query" : "browse";
718
+ const metricAuthorizationScope = authorizationScope?.kind === "scoped" ? "scoped" : "workspace";
719
+ if ((query.query || query.subject) && !settings.workDiscoveryEnabled) {
720
+ observeWorkDiscovery(deps.observability, {
721
+ surface: "agent_topology",
722
+ mode,
723
+ outcome: "disabled",
724
+ authorizationScope: metricAuthorizationScope,
725
+ durationMs: performance.now() - startedAtMs,
726
+ responseBytes: 0,
727
+ resultCount: 0,
728
+ overlapCount: 0,
729
+ matchCounts: {},
730
+ });
731
+ throw new HTTPException(503, {
732
+ message: "Agent work discovery is disabled by the operator.",
733
+ });
734
+ }
735
+ try {
736
+ const orderBy: SessionDiscoveryOrderBy =
737
+ query.query || query.subject ? "relevance" : "updatedAt";
738
+ const page = await listSessionDiscoverySummaries(db, workspaceId, {
739
+ limit: query.limit,
740
+ orderBy,
741
+ subjectId: grant.subjectId,
742
+ ...(query.cursor ? { cursor: query.cursor } : {}),
743
+ ...(query.parentSessionId !== undefined ? { parentSessionId: query.parentSessionId } : {}),
744
+ ...(query.rootSessionId ? { rootSessionId: query.rootSessionId } : {}),
745
+ ...(query.query ? { query: query.query } : {}),
746
+ ...(query.statuses ? { statuses: query.statuses } : {}),
747
+ activeOnly: query.activeOnly,
748
+ ...(query.recentHours !== undefined ? { recentHours: query.recentHours } : {}),
749
+ ...(query.subject ? { subject: query.subject } : {}),
750
+ ...(query.claimLimit !== undefined ? { claimLimit: query.claimLimit } : {}),
751
+ includeWorkDiscovery: settings.workDiscoveryEnabled,
752
+ ...(authorizationScope ? { authorizationScope } : {}),
753
+ });
754
+ const ancestorPaths =
755
+ query.query || query.subject
756
+ ? await listSessionDiscoveryAncestorPaths(
757
+ db,
758
+ workspaceId,
759
+ page.sessions.map((session) => session.id),
760
+ authorizationScope ?? undefined,
761
+ grant.subjectId,
762
+ )
763
+ : new Map<string, SessionDiscoveryAncestor[]>();
764
+ const sessions: AgentTopologyPageResponse["sessions"] = page.sessions.map((session) => {
765
+ const blocker = session.effectiveControl.primaryBlocker;
766
+ return {
767
+ id: session.id,
768
+ title: session.title,
732
769
  titleTruncated:
733
- ancestor.titleOriginalChars !== null &&
734
- ancestor.titleOriginalChars > Array.from(ancestor.title ?? "").length,
735
- })),
736
- status: session.status,
737
- pause: {
738
- state: session.effectiveControl.state,
739
- additionalBlockerCount: session.effectiveControl.additionalBlockerCount,
740
- source: blocker
770
+ session.titleOriginalChars !== null &&
771
+ session.titleOriginalChars > Array.from(session.title ?? "").length,
772
+ parentSessionId: session.parentSessionId,
773
+ rootSessionId: session.rootSessionId,
774
+ nestedAgentDepth: session.nestedAgentDepth,
775
+ ancestorPath: (ancestorPaths.get(session.id) ?? []).map((ancestor) => ({
776
+ id: ancestor.id,
777
+ title: ancestor.title,
778
+ titleTruncated:
779
+ ancestor.titleOriginalChars !== null &&
780
+ ancestor.titleOriginalChars > Array.from(ancestor.title ?? "").length,
781
+ })),
782
+ status: session.status,
783
+ goal: session.goal
741
784
  ? {
742
- kind: blocker.kind,
743
- ...(blocker.sessionId ? { sessionId: blocker.sessionId } : {}),
744
- displayName: blocker.displayName,
745
- displayNameTruncated:
746
- blocker.displayNameOriginalChars > Array.from(blocker.displayName).length,
785
+ status: session.goal.status,
786
+ summary: session.goal.text,
787
+ summaryTruncated:
788
+ session.goal.textOriginalChars > Array.from(session.goal.text).length,
747
789
  }
748
790
  : null,
749
- },
750
- children: session.treeStats,
751
- createdAt: session.createdAt,
752
- updatedAt: session.updatedAt,
753
- };
754
- });
755
- return c.json({
756
- sessions,
757
- total: page.total,
758
- hasMore: page.hasMore,
759
- nextCursor: page.nextCursor
760
- ? encodeAgentTopologyCursor({
761
- cursor: page.nextCursor,
762
- parentSessionId: query.parentSessionId,
763
- search: query.search ?? null,
764
- })
765
- : null,
766
- } satisfies AgentTopologyPageResponse);
791
+ pause: {
792
+ state: session.effectiveControl.state,
793
+ additionalBlockerCount: session.effectiveControl.additionalBlockerCount,
794
+ source: blocker
795
+ ? {
796
+ kind: blocker.kind,
797
+ ...(blocker.sessionId ? { sessionId: blocker.sessionId } : {}),
798
+ displayName: blocker.displayName,
799
+ displayNameTruncated:
800
+ blocker.displayNameOriginalChars > Array.from(blocker.displayName).length,
801
+ }
802
+ : null,
803
+ },
804
+ children: session.treeStats,
805
+ relatedWork: session.workDiscovery,
806
+ createdAt: session.createdAt,
807
+ updatedAt: session.updatedAt,
808
+ };
809
+ });
810
+ const response = {
811
+ sessions,
812
+ total: page.total,
813
+ hasMore: page.hasMore,
814
+ humanAdvisoriesEnabled:
815
+ settings.workDiscoveryEnabled && settings.workDiscoveryHumanAdvisoriesEnabled,
816
+ nextCursor: page.nextCursor
817
+ ? encodeAgentTopologyCursor({
818
+ cursor: page.nextCursor,
819
+ parentSessionId: query.parentSessionId === undefined ? "all" : query.parentSessionId,
820
+ rootSessionId: query.rootSessionId ?? null,
821
+ query: query.query ?? null,
822
+ statuses: query.statuses ?? [],
823
+ activeOnly: query.activeOnly,
824
+ recentHours: query.recentHours ?? null,
825
+ subject: query.subject ?? null,
826
+ claimLimit: query.claimLimit ?? null,
827
+ })
828
+ : null,
829
+ } satisfies AgentTopologyPageResponse;
830
+ observeWorkDiscovery(deps.observability, {
831
+ surface: "agent_topology",
832
+ mode,
833
+ outcome: sessions.length === 0 ? "empty" : "ok",
834
+ authorizationScope: metricAuthorizationScope,
835
+ durationMs: performance.now() - startedAtMs,
836
+ responseBytes: Buffer.byteLength(JSON.stringify(response), "utf8"),
837
+ ...summarizeWorkDiscoveryRows(sessions),
838
+ });
839
+ return c.json(response);
840
+ } catch (error) {
841
+ observeWorkDiscovery(deps.observability, {
842
+ surface: "agent_topology",
843
+ mode,
844
+ outcome: "error",
845
+ authorizationScope: metricAuthorizationScope,
846
+ durationMs: performance.now() - startedAtMs,
847
+ responseBytes: 0,
848
+ resultCount: 0,
849
+ overlapCount: 0,
850
+ matchCounts: {},
851
+ });
852
+ throw error;
853
+ }
767
854
  });
768
855
 
769
856
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId", async (c) => {
@@ -1572,7 +1659,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1572
1659
  // while the actively-working label remains independent of acknowledgment.
1573
1660
  app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/attention", async (c) => {
1574
1661
  const workspaceId = c.req.param("workspaceId");
1575
- const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
1662
+ const authorization = await requireAccessGrantAuthorization(
1663
+ c,
1664
+ deps,
1665
+ workspaceId,
1666
+ "sessions:read",
1667
+ );
1668
+ const grant = authorization.grant;
1576
1669
  const sessionId = c.req.param("sessionId");
1577
1670
  if (!z.string().uuid().safeParse(sessionId).success) {
1578
1671
  throw new HTTPException(404, { message: "session not found" });
@@ -1586,6 +1679,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1586
1679
  workspaceId,
1587
1680
  subjectId: grant.subjectId,
1588
1681
  sessionId,
1682
+ personalWorkspaceOwnerException: authorization.canonicalManagedHumanSession,
1589
1683
  ...parsed.data,
1590
1684
  });
1591
1685
  if (!session) throw new HTTPException(404, { message: "session not found" });
@@ -1618,7 +1712,13 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1618
1712
  // for this member and remain recoverable through the archived list view.
1619
1713
  app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/archive", async (c) => {
1620
1714
  const workspaceId = c.req.param("workspaceId");
1621
- const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
1715
+ const authorization = await requireAccessGrantAuthorization(
1716
+ c,
1717
+ deps,
1718
+ workspaceId,
1719
+ "sessions:read",
1720
+ );
1721
+ const grant = authorization.grant;
1622
1722
  const sessionId = c.req.param("sessionId");
1623
1723
  const parsed = UpdateSessionArchiveRequest.safeParse(await c.req.json().catch(() => null));
1624
1724
  if (!parsed.success) {
@@ -1629,6 +1729,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1629
1729
  workspaceId,
1630
1730
  subjectId: grant.subjectId,
1631
1731
  sessionId,
1732
+ personalWorkspaceOwnerException: authorization.canonicalManagedHumanSession,
1632
1733
  ...parsed.data,
1633
1734
  });
1634
1735
  if (!session) throw new HTTPException(404, { message: "session not found" });
@@ -2559,6 +2660,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2559
2660
  c.req.raw.signal,
2560
2661
  {
2561
2662
  observability: deps.observability,
2663
+ actorEpoch: getManagedAuthRequestActorEpoch(c.req.raw) ?? undefined,
2562
2664
  reauthorizeAfterMs:
2563
2665
  authorization?.reauthorizeAfterMs ?? SESSION_AUTHORIZATION_DEFAULT_REAUTHORIZE_MS,
2564
2666
  reauthorize: async () => {
@@ -2832,46 +2934,15 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2832
2934
  const sessionId = c.req.param("sessionId");
2833
2935
  await assertSessionExists(db, workspaceId, sessionId);
2834
2936
  const payload = SubmitComposerDraftRequest.parse(await c.req.json().catch(() => null));
2835
- let result: Awaited<ReturnType<typeof acceptSessionUserMessageWithOutcome>>;
2937
+ let result: Awaited<ReturnType<typeof submitComposerDraftForRequest>>;
2836
2938
  try {
2837
- result = await acceptSessionUserMessageWithOutcome(deps, grant, workspaceId, sessionId, {
2838
- text: payload.text,
2839
- annotations: payload.annotations,
2840
- modelContext: payload.modelContext ?? null,
2841
- resources: payload.resources,
2842
- model: payload.model,
2843
- reasoningEffort: payload.reasoningEffort,
2844
- latencyMode: payload.latencyMode,
2845
- mcpCredentialUpdates: payload.mcpCredentialUpdates ?? [],
2846
- connectionAuthorities: payload.connectionAuthorities,
2847
- ...(payload.personalResourceAttachment
2848
- ? { personalResourceAttachment: payload.personalResourceAttachment }
2849
- : {}),
2939
+ result = await submitComposerDraftForRequest(deps, grant, workspaceId, sessionId, payload, {
2850
2940
  authorization,
2851
- delivery: payload.delivery,
2852
- origin: "human",
2853
- expectedDraftRevision: payload.expectedDraftRevision,
2854
- clientEventId: payload.clientEventId,
2855
- ...(payload.controlEtag ? { controlEtag: payload.controlEtag } : {}),
2856
2941
  });
2857
2942
  } catch (error) {
2858
2943
  return commandConflictResponse(c, error);
2859
2944
  }
2860
- if (!result.draft) {
2861
- throw new Error("Accepted composer draft submission did not return its next draft");
2862
- }
2863
- return c.json(
2864
- {
2865
- accepted: result.accepted,
2866
- turn: result.turn,
2867
- draft: result.draft,
2868
- receipt: result.receipt,
2869
- routing: result.routing,
2870
- interruptionCount: result.interruptionCount,
2871
- replay: result.replay,
2872
- },
2873
- 202,
2874
- );
2945
+ return c.json(result, 202);
2875
2946
  });
2876
2947
 
2877
2948
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/events", async (c) => {
@@ -2995,10 +3066,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2995
3066
  }
2996
3067
  if (accepted.action === "conflict") {
2997
3068
  throw new HTTPException(409, {
2998
- message: `human-input request is ${accepted.request.status}`,
3069
+ message: "human-input request is not currently actionable",
2999
3070
  });
3000
3071
  }
3001
- return c.json(accepted.event, 202);
3072
+ return c.json(accepted.event, accepted.action === "completed" ? 200 : 202);
3002
3073
  }
3003
3074
  });
3004
3075
 
@@ -4524,12 +4595,18 @@ function sessionListQuery(
4524
4595
 
4525
4596
  export type AgentTopologyCursorEnvelope = {
4526
4597
  cursor: SessionDiscoveryCursor;
4527
- parentSessionId: string | null;
4528
- search: string | null;
4598
+ parentSessionId: string | null | "all";
4599
+ rootSessionId: string | null;
4600
+ query: string | null;
4601
+ statuses: SessionStatus[];
4602
+ activeOnly: boolean;
4603
+ recentHours: number | null;
4604
+ subject: WorkClaimSubjectFilter | null;
4605
+ claimLimit: number | null;
4529
4606
  };
4530
4607
 
4531
4608
  export function encodeAgentTopologyCursor(value: AgentTopologyCursorEnvelope): string {
4532
- return Buffer.from(JSON.stringify({ v: 1, ...value }), "utf8").toString("base64url");
4609
+ return Buffer.from(JSON.stringify({ v: 2, ...value }), "utf8").toString("base64url");
4533
4610
  }
4534
4611
 
4535
4612
  function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
@@ -4539,25 +4616,92 @@ function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
4539
4616
  });
4540
4617
  }
4541
4618
  try {
4542
- const parsed = z
4619
+ const decoded = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as unknown;
4620
+ const legacy = z
4543
4621
  .object({
4544
4622
  v: z.literal(1),
4545
4623
  parentSessionId: z.string().uuid().nullable(),
4546
- search: z.string().max(200).nullable(),
4624
+ search: z.string().max(WORK_DISCOVERY_QUERY_MAX_CHARS).nullable(),
4547
4625
  cursor: z.object({
4548
- orderBy: z.enum(["createdAt", "updatedAt"]),
4626
+ orderBy: z.literal("updatedAt"),
4549
4627
  sortRevision: z.string().max(64),
4550
4628
  sortAt: z.string().max(64),
4551
4629
  id: z.string().uuid(),
4552
4630
  snapshotAt: z.string().max(64),
4553
4631
  snapshotRevision: z.string().max(64),
4554
- updatedAfter: z.string().max(64).nullable(),
4632
+ updatedAfter: z.null(),
4555
4633
  }),
4556
4634
  })
4557
- .parse(JSON.parse(Buffer.from(value, "base64url").toString("utf8")));
4635
+ .safeParse(decoded);
4636
+ if (legacy.success) {
4637
+ if (legacy.data.search !== null) {
4638
+ throw new Error("legacy search cursor is not relevance-fenced");
4639
+ }
4640
+ const cursor = {
4641
+ ...legacy.data.cursor,
4642
+ sortRank: null,
4643
+ filterHash: null,
4644
+ } satisfies SessionDiscoveryCursor;
4645
+ return {
4646
+ cursor,
4647
+ parentSessionId: legacy.data.parentSessionId,
4648
+ rootSessionId: null,
4649
+ query: null,
4650
+ statuses: [],
4651
+ activeOnly: false,
4652
+ recentHours: null,
4653
+ subject: null,
4654
+ claimLimit: null,
4655
+ };
4656
+ }
4657
+ const parsed = z
4658
+ .object({
4659
+ v: z.literal(2),
4660
+ parentSessionId: z.union([z.string().uuid(), z.literal("all"), z.null()]),
4661
+ rootSessionId: z.string().uuid().nullable(),
4662
+ query: z.string().max(WORK_DISCOVERY_QUERY_MAX_CHARS).nullable(),
4663
+ statuses: z
4664
+ .array(
4665
+ z.enum([
4666
+ "queued",
4667
+ "running",
4668
+ "idle",
4669
+ "requires_action",
4670
+ "recovering",
4671
+ "waiting_capacity",
4672
+ "failed",
4673
+ "cancelled",
4674
+ ]),
4675
+ )
4676
+ .max(8),
4677
+ activeOnly: z.boolean(),
4678
+ recentHours: z.number().int().positive().max(WORK_DISCOVERY_RECENT_HOURS_MAX).nullable(),
4679
+ subject: z
4680
+ .object({
4681
+ namespace: z.string().min(1).max(WORK_CLAIM_NAMESPACE_MAX_BYTES),
4682
+ type: WorkClaimSubjectType,
4683
+ canonicalKey: z.string().min(1).max(WORK_CLAIM_CANONICAL_KEY_MAX_BYTES),
4684
+ })
4685
+ .strict()
4686
+ .nullable(),
4687
+ claimLimit: z.number().int().positive().max(WORK_CLAIM_DISCOVERY_LIMIT).nullable(),
4688
+ cursor: z.object({
4689
+ orderBy: z.enum(["updatedAt", "relevance"]),
4690
+ sortRank: z.number().int().nonnegative().nullable(),
4691
+ sortRevision: z.string().max(64),
4692
+ sortAt: z.string().max(64),
4693
+ id: z.string().uuid(),
4694
+ snapshotAt: z.string().max(64),
4695
+ snapshotRevision: z.string().max(64),
4696
+ updatedAfter: z.null(),
4697
+ filterHash: z
4698
+ .string()
4699
+ .regex(/^[0-9a-f]{64}$/)
4700
+ .nullable(),
4701
+ }),
4702
+ })
4703
+ .parse(decoded);
4558
4704
  if (
4559
- parsed.cursor.orderBy !== "updatedAt" ||
4560
- parsed.cursor.updatedAfter !== null ||
4561
4705
  !/^(?:0|[1-9]\d*)$/.test(parsed.cursor.sortRevision) ||
4562
4706
  !/^(?:0|[1-9]\d*)$/.test(parsed.cursor.snapshotRevision) ||
4563
4707
  BigInt(parsed.cursor.sortRevision) > 9_223_372_036_854_775_807n ||
@@ -4567,6 +4711,14 @@ function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
4567
4711
  ) {
4568
4712
  throw new Error("invalid topology cursor fields");
4569
4713
  }
4714
+ if (
4715
+ (parsed.cursor.orderBy === "relevance" &&
4716
+ (parsed.cursor.sortRank === null || parsed.cursor.filterHash === null)) ||
4717
+ (parsed.cursor.orderBy === "updatedAt" &&
4718
+ (parsed.cursor.sortRank !== null || parsed.cursor.filterHash !== null))
4719
+ ) {
4720
+ throw new Error("invalid topology cursor relevance fields");
4721
+ }
4570
4722
  return parsed;
4571
4723
  } catch {
4572
4724
  throw new HTTPException(400, {
@@ -4577,8 +4729,14 @@ function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
4577
4729
 
4578
4730
  export function agentTopologyQuery(query: Record<string, string>): {
4579
4731
  limit: number;
4580
- parentSessionId: string | null;
4581
- search: string | undefined;
4732
+ parentSessionId: string | null | undefined;
4733
+ rootSessionId: string | undefined;
4734
+ query: string | undefined;
4735
+ statuses: SessionStatus[] | undefined;
4736
+ activeOnly: boolean;
4737
+ recentHours: number | undefined;
4738
+ subject: WorkClaimSubjectFilter | undefined;
4739
+ claimLimit: number | undefined;
4582
4740
  cursor: SessionDiscoveryCursor | undefined;
4583
4741
  } {
4584
4742
  const rawLimit = query.limit;
@@ -4598,22 +4756,139 @@ export function agentTopologyQuery(query: Record<string, string>): {
4598
4756
  message: 'parentSessionId must be a session id or the literal "null"',
4599
4757
  });
4600
4758
  }
4601
- const parentSessionId = rawParent === undefined || rawParent === "null" ? null : rawParent;
4602
- const search = query.search?.trim();
4603
- if (search && search.length > 200) {
4759
+ const rootSessionId = query.rootSessionId?.trim();
4760
+ if (rootSessionId && !z.string().uuid().safeParse(rootSessionId).success) {
4761
+ throw new HTTPException(400, { message: "rootSessionId must be a session id" });
4762
+ }
4763
+ const normalizeSearchQuery = (value: string | undefined): string | undefined => {
4764
+ if (value === undefined) return undefined;
4765
+ const canonical = value.normalize("NFKC");
4766
+ if (/[\u0000-\u001f\u007f-\u009f]/u.test(canonical)) {
4767
+ throw new HTTPException(400, { message: "query must not contain control characters" });
4768
+ }
4769
+ const normalized = canonical.trim().replace(/\s+/gu, " ").toLowerCase();
4770
+ if (!normalized) return undefined;
4771
+ if (Array.from(normalized).length > WORK_DISCOVERY_QUERY_MAX_CHARS) {
4772
+ throw new HTTPException(400, {
4773
+ message: `query must be at most ${WORK_DISCOVERY_QUERY_MAX_CHARS} characters`,
4774
+ });
4775
+ }
4776
+ return normalized;
4777
+ };
4778
+ const requestedQuery = normalizeSearchQuery(query.query);
4779
+ const legacySearch = normalizeSearchQuery(query.search);
4780
+ const searchQuery = requestedQuery ?? legacySearch;
4781
+ if (requestedQuery && legacySearch && requestedQuery !== legacySearch) {
4782
+ throw new HTTPException(400, { message: "query and legacy search must match" });
4783
+ }
4784
+ const statuses = query.statuses
4785
+ ? [
4786
+ ...new Set(
4787
+ query.statuses
4788
+ .split(",")
4789
+ .map((status) => status.trim())
4790
+ .filter(Boolean),
4791
+ ),
4792
+ ].sort()
4793
+ : [];
4794
+ const parsedStatuses = z
4795
+ .array(
4796
+ z.enum([
4797
+ "queued",
4798
+ "running",
4799
+ "idle",
4800
+ "requires_action",
4801
+ "recovering",
4802
+ "waiting_capacity",
4803
+ "failed",
4804
+ "cancelled",
4805
+ ]),
4806
+ )
4807
+ .max(8)
4808
+ .safeParse(statuses);
4809
+ if (!parsedStatuses.success) {
4810
+ throw new HTTPException(400, { message: "statuses contains an unsupported lifecycle state" });
4811
+ }
4812
+ const activeOnly = query.activeOnly === "true";
4813
+ if (
4814
+ query.activeOnly !== undefined &&
4815
+ query.activeOnly !== "true" &&
4816
+ query.activeOnly !== "false"
4817
+ ) {
4818
+ throw new HTTPException(400, { message: "activeOnly must be true or false" });
4819
+ }
4820
+ const recentHours = query.recentHours === undefined ? undefined : Number(query.recentHours);
4821
+ if (
4822
+ recentHours !== undefined &&
4823
+ (!Number.isSafeInteger(recentHours) ||
4824
+ recentHours < 1 ||
4825
+ recentHours > WORK_DISCOVERY_RECENT_HOURS_MAX)
4826
+ ) {
4604
4827
  throw new HTTPException(400, {
4605
- message: "search must be at most 200 characters",
4828
+ message: `recentHours must be an integer between 1 and ${WORK_DISCOVERY_RECENT_HOURS_MAX}`,
4606
4829
  });
4607
4830
  }
4608
- if (search && rawParent !== undefined) {
4831
+ const subjectFields = [query.subjectNamespace, query.subjectType, query.subjectKey];
4832
+ if (subjectFields.some((value) => value !== undefined) && subjectFields.some((value) => !value)) {
4609
4833
  throw new HTTPException(400, {
4610
- message: "search cannot be combined with parentSessionId",
4834
+ message: "subjectNamespace, subjectType, and subjectKey must be supplied together",
4835
+ });
4836
+ }
4837
+ const parsedSubject = subjectFields.every((value) => value !== undefined)
4838
+ ? WorkClaimSubjectFilterSchema.safeParse({
4839
+ namespace: normalizeWorkClaimNamespace(query.subjectNamespace!),
4840
+ type: query.subjectType,
4841
+ canonicalKey: normalizeWorkClaimCanonicalKey(query.subjectKey!),
4842
+ })
4843
+ : null;
4844
+ if (parsedSubject && !parsedSubject.success) {
4845
+ throw new HTTPException(400, { message: "exact subject filter is invalid" });
4846
+ }
4847
+ const subject = parsedSubject?.success ? parsedSubject.data : undefined;
4848
+ if (searchQuery && subject) {
4849
+ throw new HTTPException(400, { message: "query cannot be combined with an exact subject" });
4850
+ }
4851
+ const relevanceRequested = Boolean(searchQuery || subject);
4852
+ const parentSessionId =
4853
+ rawParent === undefined
4854
+ ? relevanceRequested
4855
+ ? undefined
4856
+ : null
4857
+ : rawParent === "null"
4858
+ ? null
4859
+ : rawParent;
4860
+ const claimLimit = query.claimLimit === undefined ? undefined : Number(query.claimLimit);
4861
+ if (
4862
+ claimLimit !== undefined &&
4863
+ (!Number.isSafeInteger(claimLimit) || claimLimit < 1 || claimLimit > WORK_CLAIM_DISCOVERY_LIMIT)
4864
+ ) {
4865
+ throw new HTTPException(400, {
4866
+ message: `claimLimit must be an integer between 1 and ${WORK_CLAIM_DISCOVERY_LIMIT}`,
4611
4867
  });
4612
4868
  }
4613
4869
  const envelope = query.cursor ? decodeAgentTopologyCursor(query.cursor) : undefined;
4870
+ const expectedEnvelope = {
4871
+ parentSessionId: parentSessionId === undefined ? "all" : parentSessionId,
4872
+ rootSessionId: rootSessionId ?? null,
4873
+ query: searchQuery || null,
4874
+ statuses: parsedStatuses.data,
4875
+ activeOnly,
4876
+ recentHours: recentHours ?? null,
4877
+ subject: subject ?? null,
4878
+ claimLimit: claimLimit ?? null,
4879
+ };
4614
4880
  if (
4615
4881
  envelope &&
4616
- (envelope.parentSessionId !== parentSessionId || envelope.search !== (search || null))
4882
+ JSON.stringify({
4883
+ parentSessionId: envelope.parentSessionId,
4884
+ rootSessionId: envelope.rootSessionId,
4885
+ query: envelope.query,
4886
+ statuses: envelope.statuses,
4887
+ activeOnly: envelope.activeOnly,
4888
+ recentHours: envelope.recentHours,
4889
+ subject: envelope.subject,
4890
+ claimLimit: envelope.claimLimit,
4891
+ }) !== JSON.stringify(expectedEnvelope)
4617
4892
  ) {
4618
4893
  throw new HTTPException(400, {
4619
4894
  message: "agent topology cursor does not match its filters",
@@ -4622,7 +4897,13 @@ export function agentTopologyQuery(query: Record<string, string>): {
4622
4897
  return {
4623
4898
  limit,
4624
4899
  parentSessionId,
4625
- search: search || undefined,
4900
+ rootSessionId: rootSessionId || undefined,
4901
+ query: searchQuery || undefined,
4902
+ statuses: parsedStatuses.data.length > 0 ? parsedStatuses.data : undefined,
4903
+ activeOnly,
4904
+ recentHours,
4905
+ subject,
4906
+ claimLimit,
4626
4907
  cursor: envelope?.cursor,
4627
4908
  };
4628
4909
  }