@opengeni/api-router 0.9.0 → 0.11.1

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.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createApp
3
- } from "./chunk-QOYQBYHM.js";
3
+ } from "./chunk-7PQKPKW5.js";
4
4
 
5
5
  // src/index.ts
6
6
  import {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/api-router",
3
- "version": "0.9.0",
3
+ "version": "0.11.1",
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": {
@@ -44,17 +44,17 @@
44
44
  "@modelcontextprotocol/sdk": "^1.29.0",
45
45
  "@opengeni/agent-proto": "^0.3.0",
46
46
  "@opengeni/codex": "^0.2.7",
47
- "@opengeni/config": "^0.6.9",
48
- "@opengeni/contracts": "^0.18.0",
49
- "@opengeni/core": "^0.10.0",
50
- "@opengeni/db": "^0.10.7",
51
- "@opengeni/documents": "^0.2.28",
52
- "@opengeni/events": "^0.3.19",
53
- "@opengeni/github": "^0.3.10",
47
+ "@opengeni/config": "^0.7.0",
48
+ "@opengeni/contracts": "^0.19.0",
49
+ "@opengeni/core": "^0.11.1",
50
+ "@opengeni/db": "^0.12.0",
51
+ "@opengeni/documents": "^0.2.30",
52
+ "@opengeni/events": "^0.3.21",
53
+ "@opengeni/github": "^0.3.12",
54
54
  "@opengeni/network": "^0.1.1",
55
55
  "@opengeni/observability": "^0.3.0",
56
- "@opengeni/runtime": "^0.13.0",
57
- "@opengeni/storage": "^0.2.22",
56
+ "@opengeni/runtime": "^0.13.3",
57
+ "@opengeni/storage": "^0.2.24",
58
58
  "@temporalio/client": "^1.17.0",
59
59
  "better-auth": "^1.6.14",
60
60
  "hono": "^4.12.18",
package/src/mcp/server.ts CHANGED
@@ -58,15 +58,15 @@ import {
58
58
  saveWorkspaceMemory,
59
59
  searchWorkspaceMemories,
60
60
  serializeEffectiveSessionControl,
61
- setSessionGoalStatus,
61
+ setSessionGoalStatusWithEvent,
62
62
  setVariableSetVariable,
63
63
  updateScheduledTask,
64
- updateSessionGoal,
65
- upsertSessionGoal,
64
+ updateSessionGoalWithEvent,
65
+ upsertSessionGoalWithEvent,
66
66
  RigChangeAlreadyVerifyingError,
67
67
  RigChangeTransitionError,
68
68
  } from "@opengeni/db";
69
- import { appendAndPublishEvents } from "@opengeni/events";
69
+ import { appendAndPublishEvents, publishDurableSessionEvents } from "@opengeni/events";
70
70
  import {
71
71
  createGitHubAppInstallationToken,
72
72
  GitHubAppConfigurationError,
@@ -109,6 +109,8 @@ import {
109
109
  controlAgentSessionWorkstream,
110
110
  controlHumanSessionWorkstream,
111
111
  createSessionForRequest,
112
+ SessionSpawnDeniedError,
113
+ sessionSpawnDenialEnvelope,
112
114
  sendAgentSessionMessage,
113
115
  steerAgentSession,
114
116
  updateSessionTitle,
@@ -135,6 +137,7 @@ import {
135
137
  SESSION_EVENT_MCP_MAX_BYTES,
136
138
  } from "./session-view";
137
139
  import type { ToolspaceMcpSurface } from "./toolspace";
140
+ import { ensureSessionGroupReady as ensureViewerSessionGroupReady } from "../sandbox/viewer";
138
141
 
139
142
  export type McpServerOptions = {
140
143
  // Origin of the HTTP request that reached the MCP route. Retained in the
@@ -739,7 +742,7 @@ function registerGoalTools(
739
742
  ? (grant.metadata["turnId"] as string)
740
743
  : null;
741
744
  await assertGoalReactivationAllowed(deps, grant.workspaceId, sessionId, callerTurnId);
742
- const { goal, replaced } = await upsertSessionGoal(deps.db, {
745
+ const { goal, events } = await upsertSessionGoalWithEvent(deps.db, {
743
746
  accountId: grant.accountId,
744
747
  workspaceId: grant.workspaceId,
745
748
  sessionId,
@@ -747,20 +750,11 @@ function registerGoalTools(
747
750
  successCriteria: successCriteria ?? null,
748
751
  maxAutoContinuations: maxAutoContinuations ?? null,
749
752
  createdBy: "agent",
753
+ actor: "agent",
750
754
  });
751
- await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
752
- {
753
- type: "goal.set",
754
- payload: {
755
- goalId: goal.id,
756
- text: goal.text,
757
- ...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
758
- version: goal.version,
759
- actor: "agent",
760
- replaced,
761
- },
762
- },
763
- ]);
755
+ if (events.length > 0) {
756
+ await deps.bus.publish(grant.workspaceId, sessionId, events);
757
+ }
764
758
  return json(goal);
765
759
  },
766
760
  );
@@ -774,36 +768,36 @@ function registerGoalTools(
774
768
  text: z4.string().min(1).optional(),
775
769
  successCriteria: z4.string().min(1).optional(),
776
770
  progressNote: z4.string().min(1).optional(),
771
+ idempotencyKey: z4.string().uuid(),
777
772
  },
778
773
  },
779
- async ({ text, successCriteria, progressNote }) => {
774
+ async ({ text, successCriteria, progressNote, idempotencyKey }) => {
780
775
  await authorizeFirstPartySession(deps, grant, sessionId, "session.goal.write");
781
- await requireSession(deps.db, grant.workspaceId, sessionId);
782
- const existing = await getSessionGoal(deps.db, grant.workspaceId, sessionId);
783
- if (!existing) {
784
- throw new Error("this session has no goal; use goal_set first");
785
- }
786
- if (existing.status === "completed") {
787
- throw new Error("session goal is completed; use goal_set to start a new goal");
788
- }
789
- const goal = await updateSessionGoal(deps.db, grant.workspaceId, sessionId, {
790
- ...(text !== undefined ? { text } : {}),
791
- ...(successCriteria !== undefined ? { successCriteria } : {}),
792
- });
793
- await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
776
+ const context = exactAgentCommandContext(grant, sessionId);
777
+ const { goal, events, operationId, replay } = await updateSessionGoalWithEvent(
778
+ deps.db,
779
+ grant.workspaceId,
780
+ sessionId,
794
781
  {
795
- type: "goal.updated",
796
- payload: {
797
- goalId: goal.id,
798
- text: goal.text,
799
- ...(goal.successCriteria ? { successCriteria: goal.successCriteria } : {}),
800
- ...(progressNote ? { progressNote } : {}),
801
- version: goal.version,
802
- actor: "agent",
782
+ ...(text !== undefined ? { text } : {}),
783
+ ...(successCriteria !== undefined ? { successCriteria } : {}),
784
+ ...(progressNote !== undefined ? { progressNote } : {}),
785
+ actor: "agent",
786
+ command: {
787
+ accountId: grant.accountId,
788
+ actor: {
789
+ type: "agent_attempt",
790
+ attemptId: context.callerAttemptId,
791
+ sessionId: context.callerSessionId,
792
+ turnId: context.callerTurnId,
793
+ executionGeneration: context.callerExecutionGeneration,
794
+ },
795
+ operationKey: idempotencyKey,
803
796
  },
804
797
  },
805
- ]);
806
- return json(goal);
798
+ );
799
+ await publishDurableSessionEvents(deps.bus, grant.workspaceId, sessionId, events);
800
+ return json({ ...goal, operationId, replay });
807
801
  },
808
802
  );
809
803
 
@@ -821,17 +815,18 @@ function registerGoalTools(
821
815
  if (!existing) {
822
816
  throw new Error("this session has no goal; use goal_set first");
823
817
  }
824
- const { goal, changed } = await setSessionGoalStatus(deps.db, grant.workspaceId, sessionId, {
825
- status: "completed",
826
- evidence,
827
- });
828
- if (changed) {
829
- await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
830
- {
831
- type: "goal.completed",
832
- payload: { goalId: goal.id, evidence, version: goal.version },
833
- },
834
- ]);
818
+ const { goal, events } = await setSessionGoalStatusWithEvent(
819
+ deps.db,
820
+ grant.workspaceId,
821
+ sessionId,
822
+ {
823
+ status: "completed",
824
+ evidence,
825
+ event: { type: "goal.completed", evidence },
826
+ },
827
+ );
828
+ if (events.length > 0) {
829
+ await deps.bus.publish(grant.workspaceId, sessionId, events);
835
830
  }
836
831
  return json(goal);
837
832
  },
@@ -851,25 +846,24 @@ function registerGoalTools(
851
846
  if (!existing) {
852
847
  throw new Error("this session has no goal; use goal_set first");
853
848
  }
854
- const { goal, changed } = await setSessionGoalStatus(deps.db, grant.workspaceId, sessionId, {
855
- status: "paused",
856
- rationale,
857
- pausedReason: "agent",
858
- });
859
- if (changed) {
860
- await appendAndPublishEvents(deps.db, deps.bus, grant.workspaceId, sessionId, [
861
- {
849
+ const { goal, events } = await setSessionGoalStatusWithEvent(
850
+ deps.db,
851
+ grant.workspaceId,
852
+ sessionId,
853
+ {
854
+ status: "paused",
855
+ rationale,
856
+ pausedReason: "agent",
857
+ event: {
862
858
  type: "goal.paused",
863
- payload: {
864
- goalId: goal.id,
865
- actor: "agent",
866
- reason: "agent",
867
- rationale,
868
- autoContinuations: goal.autoContinuations,
869
- noProgressStreak: goal.noProgressStreak,
870
- },
859
+ actor: "agent",
860
+ reason: "agent",
861
+ rationale,
871
862
  },
872
- ]);
863
+ },
864
+ );
865
+ if (events.length > 0) {
866
+ await deps.bus.publish(grant.workspaceId, sessionId, events);
873
867
  }
874
868
  return json(goal);
875
869
  },
@@ -1038,6 +1032,13 @@ function registerFleetTools(
1038
1032
  db: deps.db,
1039
1033
  settings: deps.settings,
1040
1034
  bus: deps.bus,
1035
+ ensureSessionGroupReady: async (ctx) => {
1036
+ const session = await requireSession(deps.db, ctx.workspaceId, ctx.sessionId);
1037
+ return await ensureViewerSessionGroupReady(
1038
+ { db: deps.db, settings: deps.settings, bus: deps.bus },
1039
+ { accountId: ctx.accountId, workspaceId: ctx.workspaceId, session },
1040
+ );
1041
+ },
1041
1042
  };
1042
1043
 
1043
1044
  // Resolve the session's group sandbox (the default/home fleet member) at
@@ -1055,7 +1056,7 @@ function registerFleetTools(
1055
1056
  "sandboxes_list",
1056
1057
  {
1057
1058
  description:
1058
- "List the sandboxes this session can run on: its own session sandbox (the Modal box) PLUS the workspace's enrolled selfhosted machines, each with liveness (online/reconnecting/offline) and an `active` marker for the currently-routed one. Use before sandbox_attach/sandbox_swap to pick a target. The `id` of any entry is the `target` for attach/swap/run_on.",
1059
+ "List the sandboxes this session can run on: its own session sandbox plus enrolled selfhosted machines. `liveness` is conservative: online requires observed provider existence and verified workspace readiness. Provider, lease, route, archive, restore, workspace, lease epoch, and route epoch are also reported separately. Use an entry `id` as an attach/swap/run_on target.",
1059
1060
  inputSchema: {},
1060
1061
  },
1061
1062
  async () => json(await listFleet(services, await fleetContext())),
@@ -1065,7 +1066,7 @@ function registerFleetTools(
1065
1066
  "sandbox_attach",
1066
1067
  {
1067
1068
  description:
1068
- 'Attach this session to a sandbox (make it the active sandbox the agent\'s next tool calls run on). Heterogeneous: a Modal box or an enrolled selfhosted machine. Validates the target is owned by this workspace and online, then repoints under an epoch fence. Identical mechanic to sandbox_swap; use `target` = a sandboxes_list `id`, or "session"/"default" for this session\'s own box.',
1069
+ 'Attach this session to a sandbox for the next tool call. The target must be owned and verified ready. A same-target attach is a repair request: it revalidates readiness and advances the route epoch rather than returning unchanged success. Recovery-in-progress/degraded/unrecoverable outcomes are typed. Use a sandboxes_list `id`, or "session"/"default" for home.',
1069
1070
  inputSchema: { target: z4.string().min(1) },
1070
1071
  },
1071
1072
  async ({ target }) => json(await swapActiveSandbox(services, await fleetContext(), target)),
@@ -1075,7 +1076,7 @@ function registerFleetTools(
1075
1076
  "sandbox_swap",
1076
1077
  {
1077
1078
  description:
1078
- 'Swap the active sandbox for this session mid-conversation (the next tool call runs on the new box). Heterogeneous Modal<->selfhosted<->selfhosted, single active at a time, flippable as many times as you like. Validates ownership + liveness, then bumps the active epoch (fencing any in-flight op, which retries against the new box). `target` = a sandboxes_list `id`, or "session"/"default" to swap back to this session\'s own box.',
1079
+ 'Swap the active sandbox for this session mid-conversation. Validates ownership and verified readiness, then advances the route epoch. Same-target swaps also revalidate and fence stale route caches. An operation that encountered provider disappearance is not replayed; retry only after a typed recovery-ready result. Use a sandboxes_list `id`, or "session"/"default" for home.',
1079
1080
  inputSchema: { target: z4.string().min(1) },
1080
1081
  },
1081
1082
  async ({ target }) => json(await swapActiveSandbox(services, await fleetContext(), target)),
@@ -1575,6 +1576,9 @@ function registerWorkspaceOrchestrationTools(
1575
1576
  // Workspace-scoped CREATE idempotency key: a retried session_create with
1576
1577
  // the same key returns the already-spawned worker instead of a duplicate.
1577
1578
  idempotencyKey: z4.string().min(1).max(200).optional(),
1579
+ // Per-session/agent descendant policy. Reductions need only create;
1580
+ // increases are authorized server-side with workspace:admin.
1581
+ maxNestedAgentDepth: z4.number().int().nonnegative().optional(),
1578
1582
  // First-party MCP token permissions for the spawned session; every
1579
1583
  // permission must be held by this grant (validated in the domain).
1580
1584
  // A goal requires goals:manage in the resulting set; it is never
@@ -1619,11 +1623,21 @@ function registerWorkspaceOrchestrationTools(
1619
1623
  },
1620
1624
  },
1621
1625
  async (args) => {
1622
- if (callerSessionId !== null) {
1623
- await authorizeFirstPartySession(deps, grant, callerSessionId, "session.child.create");
1626
+ try {
1627
+ if (callerSessionId !== null) {
1628
+ await authorizeFirstPartySession(deps, grant, callerSessionId, "session.child.create");
1629
+ }
1630
+ const created = await createSessionForRequest(deps, grant, grant.workspaceId, args);
1631
+ return json(await withMcpEffectivePolicy(deps, grant.workspaceId, created));
1632
+ } catch (error) {
1633
+ if (error instanceof SessionSpawnDeniedError) {
1634
+ return {
1635
+ ...json(sessionSpawnDenialEnvelope(error)),
1636
+ isError: true,
1637
+ };
1638
+ }
1639
+ throw error;
1624
1640
  }
1625
- const created = await createSessionForRequest(deps, grant, grant.workspaceId, args);
1626
- return json(await withMcpEffectivePolicy(deps, grant.workspaceId, created));
1627
1641
  },
1628
1642
  );
1629
1643
  }
@@ -19,13 +19,14 @@ import {
19
19
  SwapActiveSandboxRequest,
20
20
  SwapActiveSandboxResponse,
21
21
  } from "@opengeni/contracts";
22
- import { getEnrollment, readMachineMetricsSeries } from "@opengeni/db";
22
+ import { getEnrollment, readMachineMetricsSeries, requireSession } from "@opengeni/db";
23
23
  import type { Hono } from "hono";
24
24
  import { HTTPException } from "hono/http-exception";
25
25
  import { requireAccessGrant } from "@opengeni/core";
26
26
  import type { ApiRouteDeps } from "@opengeni/core";
27
27
  import { buildFleetContextForSession, swapActiveSandbox } from "@opengeni/core";
28
28
  import { listMachines, metricRowToSample } from "../sandbox/machines";
29
+ import { ensureSessionGroupReady as ensureViewerSessionGroupReady } from "../sandbox/viewer";
29
30
 
30
31
  // The supported series windows → milliseconds. An unknown/absent window defaults
31
32
  // to 1h (the default). Bounded so a caller cannot request an unbounded
@@ -102,7 +103,26 @@ export function registerMachineRoutes(app: Hono, deps: ApiRouteDeps): void {
102
103
  workspaceId,
103
104
  sessionId,
104
105
  });
105
- const result = await swapActiveSandbox({ db, settings, bus }, ctx, body.target);
106
+ const result = await swapActiveSandbox(
107
+ {
108
+ db,
109
+ settings,
110
+ bus,
111
+ ensureSessionGroupReady: async (fleetCtx) => {
112
+ const session = await requireSession(db, fleetCtx.workspaceId, fleetCtx.sessionId);
113
+ return await ensureViewerSessionGroupReady(
114
+ { db, settings, bus },
115
+ {
116
+ accountId: fleetCtx.accountId,
117
+ workspaceId: fleetCtx.workspaceId,
118
+ session,
119
+ },
120
+ );
121
+ },
122
+ },
123
+ ctx,
124
+ body.target,
125
+ );
106
126
  return c.json(SwapActiveSandboxResponse.parse(result));
107
127
  });
108
128
  }