@opengeni/api-router 2.4.2-canary.0 → 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.
@@ -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 {
@@ -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) => {
@@ -4509,12 +4595,18 @@ function sessionListQuery(
4509
4595
 
4510
4596
  export type AgentTopologyCursorEnvelope = {
4511
4597
  cursor: SessionDiscoveryCursor;
4512
- parentSessionId: string | null;
4513
- 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;
4514
4606
  };
4515
4607
 
4516
4608
  export function encodeAgentTopologyCursor(value: AgentTopologyCursorEnvelope): string {
4517
- return Buffer.from(JSON.stringify({ v: 1, ...value }), "utf8").toString("base64url");
4609
+ return Buffer.from(JSON.stringify({ v: 2, ...value }), "utf8").toString("base64url");
4518
4610
  }
4519
4611
 
4520
4612
  function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
@@ -4524,25 +4616,92 @@ function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
4524
4616
  });
4525
4617
  }
4526
4618
  try {
4527
- const parsed = z
4619
+ const decoded = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as unknown;
4620
+ const legacy = z
4528
4621
  .object({
4529
4622
  v: z.literal(1),
4530
4623
  parentSessionId: z.string().uuid().nullable(),
4531
- search: z.string().max(200).nullable(),
4624
+ search: z.string().max(WORK_DISCOVERY_QUERY_MAX_CHARS).nullable(),
4625
+ cursor: z.object({
4626
+ orderBy: z.literal("updatedAt"),
4627
+ sortRevision: z.string().max(64),
4628
+ sortAt: z.string().max(64),
4629
+ id: z.string().uuid(),
4630
+ snapshotAt: z.string().max(64),
4631
+ snapshotRevision: z.string().max(64),
4632
+ updatedAfter: z.null(),
4633
+ }),
4634
+ })
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(),
4532
4688
  cursor: z.object({
4533
- orderBy: z.enum(["createdAt", "updatedAt"]),
4689
+ orderBy: z.enum(["updatedAt", "relevance"]),
4690
+ sortRank: z.number().int().nonnegative().nullable(),
4534
4691
  sortRevision: z.string().max(64),
4535
4692
  sortAt: z.string().max(64),
4536
4693
  id: z.string().uuid(),
4537
4694
  snapshotAt: z.string().max(64),
4538
4695
  snapshotRevision: z.string().max(64),
4539
- updatedAfter: z.string().max(64).nullable(),
4696
+ updatedAfter: z.null(),
4697
+ filterHash: z
4698
+ .string()
4699
+ .regex(/^[0-9a-f]{64}$/)
4700
+ .nullable(),
4540
4701
  }),
4541
4702
  })
4542
- .parse(JSON.parse(Buffer.from(value, "base64url").toString("utf8")));
4703
+ .parse(decoded);
4543
4704
  if (
4544
- parsed.cursor.orderBy !== "updatedAt" ||
4545
- parsed.cursor.updatedAfter !== null ||
4546
4705
  !/^(?:0|[1-9]\d*)$/.test(parsed.cursor.sortRevision) ||
4547
4706
  !/^(?:0|[1-9]\d*)$/.test(parsed.cursor.snapshotRevision) ||
4548
4707
  BigInt(parsed.cursor.sortRevision) > 9_223_372_036_854_775_807n ||
@@ -4552,6 +4711,14 @@ function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
4552
4711
  ) {
4553
4712
  throw new Error("invalid topology cursor fields");
4554
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
+ }
4555
4722
  return parsed;
4556
4723
  } catch {
4557
4724
  throw new HTTPException(400, {
@@ -4562,8 +4729,14 @@ function decodeAgentTopologyCursor(value: string): AgentTopologyCursorEnvelope {
4562
4729
 
4563
4730
  export function agentTopologyQuery(query: Record<string, string>): {
4564
4731
  limit: number;
4565
- parentSessionId: string | null;
4566
- 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;
4567
4740
  cursor: SessionDiscoveryCursor | undefined;
4568
4741
  } {
4569
4742
  const rawLimit = query.limit;
@@ -4583,22 +4756,139 @@ export function agentTopologyQuery(query: Record<string, string>): {
4583
4756
  message: 'parentSessionId must be a session id or the literal "null"',
4584
4757
  });
4585
4758
  }
4586
- const parentSessionId = rawParent === undefined || rawParent === "null" ? null : rawParent;
4587
- const search = query.search?.trim();
4588
- 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
+ ) {
4589
4827
  throw new HTTPException(400, {
4590
- message: "search must be at most 200 characters",
4828
+ message: `recentHours must be an integer between 1 and ${WORK_DISCOVERY_RECENT_HOURS_MAX}`,
4829
+ });
4830
+ }
4831
+ const subjectFields = [query.subjectNamespace, query.subjectType, query.subjectKey];
4832
+ if (subjectFields.some((value) => value !== undefined) && subjectFields.some((value) => !value)) {
4833
+ throw new HTTPException(400, {
4834
+ message: "subjectNamespace, subjectType, and subjectKey must be supplied together",
4591
4835
  });
4592
4836
  }
4593
- if (search && rawParent !== undefined) {
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
+ ) {
4594
4865
  throw new HTTPException(400, {
4595
- message: "search cannot be combined with parentSessionId",
4866
+ message: `claimLimit must be an integer between 1 and ${WORK_CLAIM_DISCOVERY_LIMIT}`,
4596
4867
  });
4597
4868
  }
4598
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
+ };
4599
4880
  if (
4600
4881
  envelope &&
4601
- (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)
4602
4892
  ) {
4603
4893
  throw new HTTPException(400, {
4604
4894
  message: "agent topology cursor does not match its filters",
@@ -4607,7 +4897,13 @@ export function agentTopologyQuery(query: Record<string, string>): {
4607
4897
  return {
4608
4898
  limit,
4609
4899
  parentSessionId,
4610
- 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,
4611
4907
  cursor: envelope?.cursor,
4612
4908
  };
4613
4909
  }
@@ -0,0 +1,121 @@
1
+ import type { WorkDiscoveryMatchClass } from "@opengeni/contracts";
2
+ import type { Observability } from "@opengeni/observability";
3
+
4
+ export type WorkDiscoverySurface = "first_party_mcp" | "agent_topology";
5
+ export type WorkDiscoveryMode = "browse" | "query" | "subject";
6
+ export type WorkDiscoveryOutcome = "ok" | "empty" | "disabled" | "error";
7
+ export type WorkDiscoveryAuthorizationScope = "workspace" | "scoped";
8
+
9
+ export type WorkDiscoveryObservation = {
10
+ surface: WorkDiscoverySurface;
11
+ mode: WorkDiscoveryMode;
12
+ outcome: WorkDiscoveryOutcome;
13
+ authorizationScope: WorkDiscoveryAuthorizationScope;
14
+ durationMs: number;
15
+ responseBytes: number;
16
+ resultCount: number;
17
+ overlapCount: number;
18
+ matchCounts: Partial<Record<WorkDiscoveryMatchClass, number>>;
19
+ };
20
+
21
+ type WorkDiscoveryObservedRow = {
22
+ relatedWork: {
23
+ match: { class: WorkDiscoveryMatchClass } | null;
24
+ possibleOverlap: boolean;
25
+ };
26
+ };
27
+
28
+ const RESULT_BUCKETS = [0, 1, 2, 4, 8, 16, 32, 64, 100];
29
+ const RESPONSE_BYTE_BUCKETS = [512, 1_024, 4_096, 16_384, 65_536, 128_000, 262_144];
30
+ const DURATION_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5];
31
+
32
+ export function summarizeWorkDiscoveryRows(
33
+ rows: readonly WorkDiscoveryObservedRow[],
34
+ ): Pick<WorkDiscoveryObservation, "resultCount" | "overlapCount" | "matchCounts"> {
35
+ const matchCounts: WorkDiscoveryObservation["matchCounts"] = {};
36
+ let overlapCount = 0;
37
+ for (const row of rows) {
38
+ if (row.relatedWork.possibleOverlap) overlapCount += 1;
39
+ const matchClass = row.relatedWork.match?.class;
40
+ if (matchClass) matchCounts[matchClass] = (matchCounts[matchClass] ?? 0) + 1;
41
+ }
42
+ return { resultCount: rows.length, overlapCount, matchCounts };
43
+ }
44
+
45
+ /**
46
+ * Low-cardinality work-discovery telemetry. Identifiers, search text, subject
47
+ * keys, titles, goals, claim labels, versions, and provenance are excluded by
48
+ * construction. An observability failure never changes product behavior.
49
+ */
50
+ export function observeWorkDiscovery(
51
+ observability: Observability | null | undefined,
52
+ observation: WorkDiscoveryObservation,
53
+ ): void {
54
+ if (!observability) return;
55
+ const labels = {
56
+ surface: observation.surface,
57
+ mode: observation.mode,
58
+ outcome: observation.outcome,
59
+ authorization_scope: observation.authorizationScope,
60
+ };
61
+ try {
62
+ observability.incrementCounter({
63
+ name: "opengeni_work_discovery_requests_total",
64
+ help: "Advisory work-discovery requests by bounded surface, mode, outcome, and authorization scope.",
65
+ labels,
66
+ });
67
+ observability.observeHistogram({
68
+ name: "opengeni_work_discovery_duration_seconds",
69
+ help: "Advisory work-discovery request duration in seconds.",
70
+ labels,
71
+ buckets: DURATION_BUCKETS,
72
+ value: Math.max(0, observation.durationMs) / 1_000,
73
+ });
74
+ observability.observeHistogram({
75
+ name: "opengeni_work_discovery_results",
76
+ help: "Bounded advisory work-discovery result rows per request.",
77
+ labels: { surface: observation.surface, mode: observation.mode },
78
+ buckets: RESULT_BUCKETS,
79
+ value: Math.max(0, Math.floor(observation.resultCount)),
80
+ });
81
+ observability.observeHistogram({
82
+ name: "opengeni_work_discovery_response_bytes",
83
+ help: "Serialized advisory work-discovery response bytes.",
84
+ labels: { surface: observation.surface, mode: observation.mode },
85
+ buckets: RESPONSE_BYTE_BUCKETS,
86
+ value: Math.max(0, Math.floor(observation.responseBytes)),
87
+ });
88
+ if (observation.overlapCount > 0) {
89
+ observability.incrementCounter({
90
+ name: "opengeni_work_discovery_overlap_results_total",
91
+ help: "Advisory discovery rows carrying a possible-overlap explanation.",
92
+ labels: { surface: observation.surface, mode: observation.mode },
93
+ amount: Math.floor(observation.overlapCount),
94
+ });
95
+ }
96
+ for (const matchClass of ["exact_subject", "title", "goal", "fuzzy"] as const) {
97
+ const amount = Math.floor(observation.matchCounts[matchClass] ?? 0);
98
+ if (amount < 1) continue;
99
+ observability.incrementCounter({
100
+ name: "opengeni_work_discovery_matches_total",
101
+ help: "Advisory discovery matches by stable explanation class.",
102
+ labels: {
103
+ surface: observation.surface,
104
+ mode: observation.mode,
105
+ match_class: matchClass,
106
+ },
107
+ amount,
108
+ });
109
+ }
110
+ } catch {
111
+ try {
112
+ observability.incrementCounter({
113
+ name: "opengeni_observability_observer_errors_total",
114
+ help: "Observability observer failures isolated from product execution.",
115
+ labels: { observer: "work_discovery" },
116
+ });
117
+ } catch {
118
+ // The metrics registry itself is unhealthy. Discovery remains authoritative.
119
+ }
120
+ }
121
+ }