@opengeni/api-router 2.4.2-canary.0 → 2.6.3-canary.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 (63) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/auth/managed-auth-attempt-context.d.ts +5 -1
  3. package/dist/auth/managed-auth.d.ts +24 -1
  4. package/dist/{chunk-L7GVVSSQ.js → chunk-XTLI3CBH.js} +2884 -483
  5. package/dist/chunk-XTLI3CBH.js.map +1 -0
  6. package/dist/http/sse.d.ts +9 -0
  7. package/dist/index.js +104 -4
  8. package/dist/index.js.map +1 -1
  9. package/dist/integrations/slack-interactions.d.ts +1 -1
  10. package/dist/interaction-metrics.d.ts +2 -0
  11. package/dist/mcp/company-brain-governed-writes.d.ts +1 -1
  12. package/dist/mcp/company-profile-agent-admin.d.ts +6 -6
  13. package/dist/mcp/remember.d.ts +2 -2
  14. package/dist/mcp/server.d.ts +34 -0
  15. package/dist/mcp/session-view.d.ts +1 -0
  16. package/dist/mcp/session-wait.d.ts +19 -0
  17. package/dist/routes/api-keys.d.ts +2 -0
  18. package/dist/routes/browser-sessions.d.ts +1 -0
  19. package/dist/routes/computer-sessions.d.ts +12 -0
  20. package/dist/routes/managed-auth-session-sets.d.ts +7 -0
  21. package/dist/routes/sessions.d.ts +17 -5
  22. package/dist/routes/workspaces.d.ts +1 -1
  23. package/dist/sandbox/metrics-ingestion.d.ts +16 -0
  24. package/dist/work-discovery-observability.d.ts +33 -0
  25. package/dist/workspace-delete-observability.d.ts +10 -0
  26. package/package.json +19 -19
  27. package/src/app.ts +172 -20
  28. package/src/auth/managed-auth-attempt-context.ts +40 -3
  29. package/src/auth/managed-auth-session-adapter.ts +1 -0
  30. package/src/auth/managed-auth.ts +164 -4
  31. package/src/http/sse.ts +279 -45
  32. package/src/integrations/oauth-client.ts +8 -1
  33. package/src/integrations/provider-oauth.ts +12 -2
  34. package/src/interaction-metrics.ts +30 -0
  35. package/src/mcp/company-brain-governed-writes.ts +38 -23
  36. package/src/mcp/company-profile-agent-admin.ts +7 -7
  37. package/src/mcp/remember.ts +19 -8
  38. package/src/mcp/server.ts +621 -54
  39. package/src/mcp/session-wait.ts +56 -8
  40. package/src/routes/api-integrations.ts +2 -2
  41. package/src/routes/api-keys.ts +149 -7
  42. package/src/routes/browser-sessions.ts +10 -2
  43. package/src/routes/capabilities.ts +3 -3
  44. package/src/routes/codex.ts +483 -74
  45. package/src/routes/company-profile.ts +64 -0
  46. package/src/routes/computer-sessions.ts +103 -1
  47. package/src/routes/integration-facets.ts +8 -5
  48. package/src/routes/interaction-resources.ts +7 -1
  49. package/src/routes/managed-auth-session-sets.ts +199 -2
  50. package/src/routes/organization-memberships.ts +28 -8
  51. package/src/routes/packs.ts +5 -5
  52. package/src/routes/plugins.ts +2 -2
  53. package/src/routes/scheduled-tasks.ts +9 -0
  54. package/src/routes/sessions.ts +380 -87
  55. package/src/routes/skills.ts +3 -3
  56. package/src/routes/workspaces.ts +293 -54
  57. package/src/sandbox/channel-a.ts +10 -4
  58. package/src/sandbox/machines.ts +13 -6
  59. package/src/sandbox/metrics-ingestion.ts +157 -3
  60. package/src/sandbox/viewer.ts +20 -1
  61. package/src/work-discovery-observability.ts +121 -0
  62. package/src/workspace-delete-observability.ts +75 -0
  63. package/dist/chunk-L7GVVSSQ.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 {
@@ -272,7 +284,7 @@ import {
272
284
  validateVariableSetAttachment,
273
285
  } from "@opengeni/core";
274
286
  import { assertSessionExists, boundedLimit } from "../http/common";
275
- import { sseSessionStream } from "../http/sse";
287
+ import { browserSseDeliveryOptions, sseSessionStream } from "../http/sse";
276
288
  import {
277
289
  serveWorkspaceCapture,
278
290
  serveWorkspaceCaptureFile,
@@ -280,6 +292,7 @@ import {
280
292
  } from "./workspace-capture";
281
293
  import { publishSandboxFileArtifact } from "../sandbox-file-artifacts";
282
294
  import { ApiHttpError } from "../http/api-error";
295
+ import { observeWorkDiscovery, summarizeWorkDiscoveryRows } from "../work-discovery-observability";
283
296
 
284
297
  type SessionRouteDeps = ApiRouteDeps & Pick<ViewerServices, "establishSandboxSession">;
285
298
 
@@ -700,71 +713,144 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
700
713
  throw sessionAuthorizationHttpError(error);
701
714
  }
702
715
  const query = agentTopologyQuery(c.req.query());
703
- const page = await listSessionDiscoverySummaries(db, workspaceId, {
704
- limit: query.limit,
705
- orderBy: "updatedAt",
706
- subjectId: grant.subjectId,
707
- ...(query.cursor ? { cursor: query.cursor } : {}),
708
- ...(query.search ? { search: query.search } : { parentSessionId: query.parentSessionId }),
709
- ...(authorizationScope ? { authorizationScope } : {}),
710
- });
711
- const ancestorPaths = query.search
712
- ? await listSessionDiscoveryAncestorPaths(
713
- db,
714
- workspaceId,
715
- page.sessions.map((session) => session.id),
716
- authorizationScope ?? undefined,
717
- )
718
- : new Map<string, SessionDiscoveryAncestor[]>();
719
- const sessions: AgentTopologyPageResponse["sessions"] = page.sessions.map((session) => {
720
- const blocker = session.effectiveControl.primaryBlocker;
721
- return {
722
- id: session.id,
723
- title: session.title,
724
- titleTruncated:
725
- session.titleOriginalChars !== null &&
726
- session.titleOriginalChars > Array.from(session.title ?? "").length,
727
- parentSessionId: session.parentSessionId,
728
- rootSessionId: session.rootSessionId,
729
- nestedAgentDepth: session.nestedAgentDepth,
730
- ancestorPath: (ancestorPaths.get(session.id) ?? []).map((ancestor) => ({
731
- id: ancestor.id,
732
- 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,
733
769
  titleTruncated:
734
- ancestor.titleOriginalChars !== null &&
735
- ancestor.titleOriginalChars > Array.from(ancestor.title ?? "").length,
736
- })),
737
- status: session.status,
738
- pause: {
739
- state: session.effectiveControl.state,
740
- additionalBlockerCount: session.effectiveControl.additionalBlockerCount,
741
- 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
742
784
  ? {
743
- kind: blocker.kind,
744
- ...(blocker.sessionId ? { sessionId: blocker.sessionId } : {}),
745
- displayName: blocker.displayName,
746
- displayNameTruncated:
747
- 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,
748
789
  }
749
790
  : null,
750
- },
751
- children: session.treeStats,
752
- createdAt: session.createdAt,
753
- updatedAt: session.updatedAt,
754
- };
755
- });
756
- return c.json({
757
- sessions,
758
- total: page.total,
759
- hasMore: page.hasMore,
760
- nextCursor: page.nextCursor
761
- ? encodeAgentTopologyCursor({
762
- cursor: page.nextCursor,
763
- parentSessionId: query.parentSessionId,
764
- search: query.search ?? null,
765
- })
766
- : null,
767
- } 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
+ }
768
854
  });
769
855
 
770
856
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId", async (c) => {
@@ -2573,6 +2659,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2573
2659
  Number.isFinite(after) ? after : 0,
2574
2660
  c.req.raw.signal,
2575
2661
  {
2662
+ ...browserSseDeliveryOptions(c.req.query("transport")),
2576
2663
  observability: deps.observability,
2577
2664
  actorEpoch: getManagedAuthRequestActorEpoch(c.req.raw) ?? undefined,
2578
2665
  reauthorizeAfterMs:
@@ -3115,12 +3202,8 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
3115
3202
  // Human take-control: when the desktop is available + this policy is on
3116
3203
  // (default), the cell is mode "interactive" — the noVNC viewer drives :0
3117
3204
  // (x11vnc runs without -viewonly). Off → mode "read-only" (client disables
3118
- // take-control). Independent of the agent's computerUseReadOnly.
3205
+ // take-control). Agent interaction uses managed ComputerSession tools.
3119
3206
  desktopInteractive: settings.sandboxDesktopInteractive,
3120
- // P4.3 computer-use: the agent drives the active display; availability
3121
- // tracks the desktop tier + a desktop-capable backend.
3122
- computerUseEnabled: settings.computerUseEnabled,
3123
- computerUseReadOnly: settings.computerUseReadOnly,
3124
3207
  // Graceful degrade when scoped stream credentials cannot be minted.
3125
3208
  streamTokenSecretAvailable: !streamTokenDegraded(settings),
3126
3209
  desktopAcknowledged: acknowledged,
@@ -4509,12 +4592,18 @@ function sessionListQuery(
4509
4592
 
4510
4593
  export type AgentTopologyCursorEnvelope = {
4511
4594
  cursor: SessionDiscoveryCursor;
4512
- parentSessionId: string | null;
4513
- search: string | null;
4595
+ parentSessionId: string | null | "all";
4596
+ rootSessionId: string | null;
4597
+ query: string | null;
4598
+ statuses: SessionStatus[];
4599
+ activeOnly: boolean;
4600
+ recentHours: number | null;
4601
+ subject: WorkClaimSubjectFilter | null;
4602
+ claimLimit: number | null;
4514
4603
  };
4515
4604
 
4516
4605
  export function encodeAgentTopologyCursor(value: AgentTopologyCursorEnvelope): string {
4517
- return Buffer.from(JSON.stringify({ v: 1, ...value }), "utf8").toString("base64url");
4606
+ return Buffer.from(JSON.stringify({ v: 2, ...value }), "utf8").toString("base64url");
4518
4607
  }
4519
4608
 
4520
4609
  function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
@@ -4524,25 +4613,92 @@ function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
4524
4613
  });
4525
4614
  }
4526
4615
  try {
4527
- const parsed = z
4616
+ const decoded = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as unknown;
4617
+ const legacy = z
4528
4618
  .object({
4529
4619
  v: z.literal(1),
4530
4620
  parentSessionId: z.string().uuid().nullable(),
4531
- search: z.string().max(200).nullable(),
4621
+ search: z.string().max(WORK_DISCOVERY_QUERY_MAX_CHARS).nullable(),
4622
+ cursor: z.object({
4623
+ orderBy: z.literal("updatedAt"),
4624
+ sortRevision: z.string().max(64),
4625
+ sortAt: z.string().max(64),
4626
+ id: z.string().uuid(),
4627
+ snapshotAt: z.string().max(64),
4628
+ snapshotRevision: z.string().max(64),
4629
+ updatedAfter: z.null(),
4630
+ }),
4631
+ })
4632
+ .safeParse(decoded);
4633
+ if (legacy.success) {
4634
+ if (legacy.data.search !== null) {
4635
+ throw new Error("legacy search cursor is not relevance-fenced");
4636
+ }
4637
+ const cursor = {
4638
+ ...legacy.data.cursor,
4639
+ sortRank: null,
4640
+ filterHash: null,
4641
+ } satisfies SessionDiscoveryCursor;
4642
+ return {
4643
+ cursor,
4644
+ parentSessionId: legacy.data.parentSessionId,
4645
+ rootSessionId: null,
4646
+ query: null,
4647
+ statuses: [],
4648
+ activeOnly: false,
4649
+ recentHours: null,
4650
+ subject: null,
4651
+ claimLimit: null,
4652
+ };
4653
+ }
4654
+ const parsed = z
4655
+ .object({
4656
+ v: z.literal(2),
4657
+ parentSessionId: z.union([z.string().uuid(), z.literal("all"), z.null()]),
4658
+ rootSessionId: z.string().uuid().nullable(),
4659
+ query: z.string().max(WORK_DISCOVERY_QUERY_MAX_CHARS).nullable(),
4660
+ statuses: z
4661
+ .array(
4662
+ z.enum([
4663
+ "queued",
4664
+ "running",
4665
+ "idle",
4666
+ "requires_action",
4667
+ "recovering",
4668
+ "waiting_capacity",
4669
+ "failed",
4670
+ "cancelled",
4671
+ ]),
4672
+ )
4673
+ .max(8),
4674
+ activeOnly: z.boolean(),
4675
+ recentHours: z.number().int().positive().max(WORK_DISCOVERY_RECENT_HOURS_MAX).nullable(),
4676
+ subject: z
4677
+ .object({
4678
+ namespace: z.string().min(1).max(WORK_CLAIM_NAMESPACE_MAX_BYTES),
4679
+ type: WorkClaimSubjectType,
4680
+ canonicalKey: z.string().min(1).max(WORK_CLAIM_CANONICAL_KEY_MAX_BYTES),
4681
+ })
4682
+ .strict()
4683
+ .nullable(),
4684
+ claimLimit: z.number().int().positive().max(WORK_CLAIM_DISCOVERY_LIMIT).nullable(),
4532
4685
  cursor: z.object({
4533
- orderBy: z.enum(["createdAt", "updatedAt"]),
4686
+ orderBy: z.enum(["updatedAt", "relevance"]),
4687
+ sortRank: z.number().int().nonnegative().nullable(),
4534
4688
  sortRevision: z.string().max(64),
4535
4689
  sortAt: z.string().max(64),
4536
4690
  id: z.string().uuid(),
4537
4691
  snapshotAt: z.string().max(64),
4538
4692
  snapshotRevision: z.string().max(64),
4539
- updatedAfter: z.string().max(64).nullable(),
4693
+ updatedAfter: z.null(),
4694
+ filterHash: z
4695
+ .string()
4696
+ .regex(/^[0-9a-f]{64}$/)
4697
+ .nullable(),
4540
4698
  }),
4541
4699
  })
4542
- .parse(JSON.parse(Buffer.from(value, "base64url").toString("utf8")));
4700
+ .parse(decoded);
4543
4701
  if (
4544
- parsed.cursor.orderBy !== "updatedAt" ||
4545
- parsed.cursor.updatedAfter !== null ||
4546
4702
  !/^(?:0|[1-9]\d*)$/.test(parsed.cursor.sortRevision) ||
4547
4703
  !/^(?:0|[1-9]\d*)$/.test(parsed.cursor.snapshotRevision) ||
4548
4704
  BigInt(parsed.cursor.sortRevision) > 9_223_372_036_854_775_807n ||
@@ -4552,6 +4708,14 @@ function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
4552
4708
  ) {
4553
4709
  throw new Error("invalid topology cursor fields");
4554
4710
  }
4711
+ if (
4712
+ (parsed.cursor.orderBy === "relevance" &&
4713
+ (parsed.cursor.sortRank === null || parsed.cursor.filterHash === null)) ||
4714
+ (parsed.cursor.orderBy === "updatedAt" &&
4715
+ (parsed.cursor.sortRank !== null || parsed.cursor.filterHash !== null))
4716
+ ) {
4717
+ throw new Error("invalid topology cursor relevance fields");
4718
+ }
4555
4719
  return parsed;
4556
4720
  } catch {
4557
4721
  throw new HTTPException(400, {
@@ -4562,8 +4726,14 @@ function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
4562
4726
 
4563
4727
  export function agentTopologyQuery(query: Record<string, string>): {
4564
4728
  limit: number;
4565
- parentSessionId: string | null;
4566
- search: string | undefined;
4729
+ parentSessionId: string | null | undefined;
4730
+ rootSessionId: string | undefined;
4731
+ query: string | undefined;
4732
+ statuses: SessionStatus[] | undefined;
4733
+ activeOnly: boolean;
4734
+ recentHours: number | undefined;
4735
+ subject: WorkClaimSubjectFilter | undefined;
4736
+ claimLimit: number | undefined;
4567
4737
  cursor: SessionDiscoveryCursor | undefined;
4568
4738
  } {
4569
4739
  const rawLimit = query.limit;
@@ -4583,22 +4753,139 @@ export function agentTopologyQuery(query: Record<string, string>): {
4583
4753
  message: 'parentSessionId must be a session id or the literal "null"',
4584
4754
  });
4585
4755
  }
4586
- const parentSessionId = rawParent === undefined || rawParent === "null" ? null : rawParent;
4587
- const search = query.search?.trim();
4588
- if (search && search.length > 200) {
4756
+ const rootSessionId = query.rootSessionId?.trim();
4757
+ if (rootSessionId && !z.string().uuid().safeParse(rootSessionId).success) {
4758
+ throw new HTTPException(400, { message: "rootSessionId must be a session id" });
4759
+ }
4760
+ const normalizeSearchQuery = (value: string | undefined): string | undefined => {
4761
+ if (value === undefined) return undefined;
4762
+ const canonical = value.normalize("NFKC");
4763
+ if (/[\u0000-\u001f\u007f-\u009f]/u.test(canonical)) {
4764
+ throw new HTTPException(400, { message: "query must not contain control characters" });
4765
+ }
4766
+ const normalized = canonical.trim().replace(/\s+/gu, " ").toLowerCase();
4767
+ if (!normalized) return undefined;
4768
+ if (Array.from(normalized).length > WORK_DISCOVERY_QUERY_MAX_CHARS) {
4769
+ throw new HTTPException(400, {
4770
+ message: `query must be at most ${WORK_DISCOVERY_QUERY_MAX_CHARS} characters`,
4771
+ });
4772
+ }
4773
+ return normalized;
4774
+ };
4775
+ const requestedQuery = normalizeSearchQuery(query.query);
4776
+ const legacySearch = normalizeSearchQuery(query.search);
4777
+ const searchQuery = requestedQuery ?? legacySearch;
4778
+ if (requestedQuery && legacySearch && requestedQuery !== legacySearch) {
4779
+ throw new HTTPException(400, { message: "query and legacy search must match" });
4780
+ }
4781
+ const statuses = query.statuses
4782
+ ? [
4783
+ ...new Set(
4784
+ query.statuses
4785
+ .split(",")
4786
+ .map((status) => status.trim())
4787
+ .filter(Boolean),
4788
+ ),
4789
+ ].sort()
4790
+ : [];
4791
+ const parsedStatuses = z
4792
+ .array(
4793
+ z.enum([
4794
+ "queued",
4795
+ "running",
4796
+ "idle",
4797
+ "requires_action",
4798
+ "recovering",
4799
+ "waiting_capacity",
4800
+ "failed",
4801
+ "cancelled",
4802
+ ]),
4803
+ )
4804
+ .max(8)
4805
+ .safeParse(statuses);
4806
+ if (!parsedStatuses.success) {
4807
+ throw new HTTPException(400, { message: "statuses contains an unsupported lifecycle state" });
4808
+ }
4809
+ const activeOnly = query.activeOnly === "true";
4810
+ if (
4811
+ query.activeOnly !== undefined &&
4812
+ query.activeOnly !== "true" &&
4813
+ query.activeOnly !== "false"
4814
+ ) {
4815
+ throw new HTTPException(400, { message: "activeOnly must be true or false" });
4816
+ }
4817
+ const recentHours = query.recentHours === undefined ? undefined : Number(query.recentHours);
4818
+ if (
4819
+ recentHours !== undefined &&
4820
+ (!Number.isSafeInteger(recentHours) ||
4821
+ recentHours < 1 ||
4822
+ recentHours > WORK_DISCOVERY_RECENT_HOURS_MAX)
4823
+ ) {
4589
4824
  throw new HTTPException(400, {
4590
- message: "search must be at most 200 characters",
4825
+ message: `recentHours must be an integer between 1 and ${WORK_DISCOVERY_RECENT_HOURS_MAX}`,
4826
+ });
4827
+ }
4828
+ const subjectFields = [query.subjectNamespace, query.subjectType, query.subjectKey];
4829
+ if (subjectFields.some((value) => value !== undefined) && subjectFields.some((value) => !value)) {
4830
+ throw new HTTPException(400, {
4831
+ message: "subjectNamespace, subjectType, and subjectKey must be supplied together",
4591
4832
  });
4592
4833
  }
4593
- if (search && rawParent !== undefined) {
4834
+ const parsedSubject = subjectFields.every((value) => value !== undefined)
4835
+ ? WorkClaimSubjectFilterSchema.safeParse({
4836
+ namespace: normalizeWorkClaimNamespace(query.subjectNamespace!),
4837
+ type: query.subjectType,
4838
+ canonicalKey: normalizeWorkClaimCanonicalKey(query.subjectKey!),
4839
+ })
4840
+ : null;
4841
+ if (parsedSubject && !parsedSubject.success) {
4842
+ throw new HTTPException(400, { message: "exact subject filter is invalid" });
4843
+ }
4844
+ const subject = parsedSubject?.success ? parsedSubject.data : undefined;
4845
+ if (searchQuery && subject) {
4846
+ throw new HTTPException(400, { message: "query cannot be combined with an exact subject" });
4847
+ }
4848
+ const relevanceRequested = Boolean(searchQuery || subject);
4849
+ const parentSessionId =
4850
+ rawParent === undefined
4851
+ ? relevanceRequested
4852
+ ? undefined
4853
+ : null
4854
+ : rawParent === "null"
4855
+ ? null
4856
+ : rawParent;
4857
+ const claimLimit = query.claimLimit === undefined ? undefined : Number(query.claimLimit);
4858
+ if (
4859
+ claimLimit !== undefined &&
4860
+ (!Number.isSafeInteger(claimLimit) || claimLimit < 1 || claimLimit > WORK_CLAIM_DISCOVERY_LIMIT)
4861
+ ) {
4594
4862
  throw new HTTPException(400, {
4595
- message: "search cannot be combined with parentSessionId",
4863
+ message: `claimLimit must be an integer between 1 and ${WORK_CLAIM_DISCOVERY_LIMIT}`,
4596
4864
  });
4597
4865
  }
4598
4866
  const envelope = query.cursor ? decodeAgentTopologyCursor(query.cursor) : undefined;
4867
+ const expectedEnvelope = {
4868
+ parentSessionId: parentSessionId === undefined ? "all" : parentSessionId,
4869
+ rootSessionId: rootSessionId ?? null,
4870
+ query: searchQuery || null,
4871
+ statuses: parsedStatuses.data,
4872
+ activeOnly,
4873
+ recentHours: recentHours ?? null,
4874
+ subject: subject ?? null,
4875
+ claimLimit: claimLimit ?? null,
4876
+ };
4599
4877
  if (
4600
4878
  envelope &&
4601
- (envelope.parentSessionId !== parentSessionId || envelope.search !== (search || null))
4879
+ JSON.stringify({
4880
+ parentSessionId: envelope.parentSessionId,
4881
+ rootSessionId: envelope.rootSessionId,
4882
+ query: envelope.query,
4883
+ statuses: envelope.statuses,
4884
+ activeOnly: envelope.activeOnly,
4885
+ recentHours: envelope.recentHours,
4886
+ subject: envelope.subject,
4887
+ claimLimit: envelope.claimLimit,
4888
+ }) !== JSON.stringify(expectedEnvelope)
4602
4889
  ) {
4603
4890
  throw new HTTPException(400, {
4604
4891
  message: "agent topology cursor does not match its filters",
@@ -4607,7 +4894,13 @@ export function agentTopologyQuery(query: Record<string, string>): {
4607
4894
  return {
4608
4895
  limit,
4609
4896
  parentSessionId,
4610
- search: search || undefined,
4897
+ rootSessionId: rootSessionId || undefined,
4898
+ query: searchQuery || undefined,
4899
+ statuses: parsedStatuses.data.length > 0 ? parsedStatuses.data : undefined,
4900
+ activeOnly,
4901
+ recentHours,
4902
+ subject,
4903
+ claimLimit,
4611
4904
  cursor: envelope?.cursor,
4612
4905
  };
4613
4906
  }
@@ -55,7 +55,7 @@ export function registerSkillRoutes(
55
55
 
56
56
  app.post("/v1/workspaces/:workspaceId/skills/library/:libraryId/install", async (c) => {
57
57
  const workspaceId = c.req.param("workspaceId");
58
- const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
58
+ const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
59
59
  const libraryId = decodeURIComponent(c.req.param("libraryId"));
60
60
  const payload = InstallLibrarySkillRequest.parse(await c.req.json());
61
61
  let loaded: ReturnType<typeof loadSkillLibrarySkill>;
@@ -136,7 +136,7 @@ export function registerSkillRoutes(
136
136
 
137
137
  app.post("/v1/workspaces/:workspaceId/skills/install", async (c) => {
138
138
  const workspaceId = c.req.param("workspaceId");
139
- const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
139
+ const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
140
140
  const payload = InstallSkillRequest.parse(await c.req.json());
141
141
  const resolved = await resolveForRoute(payload.url, github);
142
142
  if (
@@ -204,7 +204,7 @@ export function registerSkillRoutes(
204
204
 
205
205
  app.delete("/v1/workspaces/:workspaceId/skills/:capabilityId", async (c) => {
206
206
  const workspaceId = c.req.param("workspaceId");
207
- const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
207
+ const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
208
208
  const capabilityId = decodeURIComponent(c.req.param("capabilityId"));
209
209
  const payload = UninstallSkillRequest.parse(await c.req.json());
210
210
  try {