@opengeni/api-router 0.5.3 → 0.5.5

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 (49) hide show
  1. package/dist/app.d.ts +9 -1
  2. package/dist/app.js +7 -1
  3. package/dist/{chunk-3HIA43CC.js → chunk-HBEJMWD3.js} +5470 -2223
  4. package/dist/chunk-HBEJMWD3.js.map +1 -0
  5. package/dist/index.d.ts +2 -1
  6. package/dist/index.js +279 -55
  7. package/dist/index.js.map +1 -1
  8. package/package.json +20 -20
  9. package/src/app.ts +583 -166
  10. package/src/auth/managed-auth.ts +32 -16
  11. package/src/http/auth.ts +8 -1
  12. package/src/http/common.ts +6 -2
  13. package/src/http/sse.ts +84 -8
  14. package/src/index.ts +178 -75
  15. package/src/integrations/oauth-client.ts +403 -120
  16. package/src/integrations/provider-domain.ts +4 -1
  17. package/src/mcp/documents.ts +173 -94
  18. package/src/mcp/server.ts +1600 -693
  19. package/src/mcp/session-view.ts +8 -2
  20. package/src/mcp/toolspace.ts +175 -84
  21. package/src/observability.ts +7 -1
  22. package/src/routes/api-keys.ts +39 -23
  23. package/src/routes/billing.ts +180 -65
  24. package/src/routes/capabilities.ts +17 -8
  25. package/src/routes/catalog-assets.ts +5 -2
  26. package/src/routes/codex.ts +244 -63
  27. package/src/routes/connections.ts +71 -33
  28. package/src/routes/documents.ts +242 -92
  29. package/src/routes/enrollments.ts +100 -70
  30. package/src/routes/environments.ts +205 -136
  31. package/src/routes/files.ts +164 -39
  32. package/src/routes/github.ts +123 -50
  33. package/src/routes/install.ts +9 -2
  34. package/src/routes/machines.ts +9 -8
  35. package/src/routes/packs.ts +141 -89
  36. package/src/routes/rigs.ts +189 -0
  37. package/src/routes/scheduled-tasks.ts +51 -9
  38. package/src/routes/sessions.ts +870 -328
  39. package/src/routes/social.ts +50 -38
  40. package/src/routes/workspace-capture.ts +238 -0
  41. package/src/routes/workspaces.ts +146 -13
  42. package/src/sandbox/access.ts +11 -3
  43. package/src/sandbox/auth-callout.ts +5 -1
  44. package/src/sandbox/channel-a.ts +104 -27
  45. package/src/sandbox/enrollment.ts +13 -3
  46. package/src/sandbox/machines.ts +68 -59
  47. package/src/sandbox/metrics-ingestion.ts +238 -17
  48. package/src/sandbox/viewer.ts +172 -46
  49. package/dist/chunk-3HIA43CC.js.map +0 -1
@@ -1,7 +1,4 @@
1
- import {
2
- CreateSocialConnectionRequest,
3
- CreateSocialPostRequest,
4
- } from "@opengeni/contracts";
1
+ import { CreateSocialConnectionRequest, CreateSocialPostRequest } from "@opengeni/contracts";
5
2
  import {
6
3
  createSocialConnection,
7
4
  createSocialPost,
@@ -29,19 +26,22 @@ export function registerSocialRoutes(app: Hono, deps: ApiRouteDeps): void {
29
26
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
30
27
  const payload = CreateSocialConnectionRequest.parse(await c.req.json());
31
28
  try {
32
- return c.json(await createSocialConnection(db, {
33
- accountId: grant.accountId,
34
- workspaceId,
35
- provider: payload.provider,
36
- accountHandle: payload.accountHandle,
37
- accountName: payload.accountName ?? null,
38
- externalAccountId: payload.externalAccountId ?? null,
39
- status: payload.status,
40
- scopes: payload.scopes,
41
- credentialRef: payload.credentialRef ?? null,
42
- tokenMetadata: payload.tokenMetadata,
43
- metadata: payload.metadata,
44
- }), 201);
29
+ return c.json(
30
+ await createSocialConnection(db, {
31
+ accountId: grant.accountId,
32
+ workspaceId,
33
+ provider: payload.provider,
34
+ accountHandle: payload.accountHandle,
35
+ accountName: payload.accountName ?? null,
36
+ externalAccountId: payload.externalAccountId ?? null,
37
+ status: payload.status,
38
+ scopes: payload.scopes,
39
+ credentialRef: payload.credentialRef ?? null,
40
+ tokenMetadata: payload.tokenMetadata,
41
+ metadata: payload.metadata,
42
+ }),
43
+ 201,
44
+ );
45
45
  } catch (error) {
46
46
  throw socialHttpException(error);
47
47
  }
@@ -51,13 +51,17 @@ export function registerSocialRoutes(app: Hono, deps: ApiRouteDeps): void {
51
51
  const workspaceId = c.req.param("workspaceId");
52
52
  await requireAccessGrant(c, deps, workspaceId, "workspace:read");
53
53
  const since = parseSince(c.req.query("since"));
54
- const connectionIds = parseConnectionIds(c.req.query("connectionIds") ?? c.req.query("connectionId"));
55
- return c.json(await listSocialPosts(db, {
56
- workspaceId,
57
- ...(connectionIds?.length ? { connectionIds } : {}),
58
- ...(since ? { since } : {}),
59
- limit: boundedLimit(c.req.query("limit")),
60
- }));
54
+ const connectionIds = parseConnectionIds(
55
+ c.req.query("connectionIds") ?? c.req.query("connectionId"),
56
+ );
57
+ return c.json(
58
+ await listSocialPosts(db, {
59
+ workspaceId,
60
+ ...(connectionIds?.length ? { connectionIds } : {}),
61
+ ...(since ? { since } : {}),
62
+ limit: boundedLimit(c.req.query("limit")),
63
+ }),
64
+ );
61
65
  });
62
66
 
63
67
  app.post("/v1/workspaces/:workspaceId/social/posts", async (c) => {
@@ -65,18 +69,21 @@ export function registerSocialRoutes(app: Hono, deps: ApiRouteDeps): void {
65
69
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
66
70
  const payload = CreateSocialPostRequest.parse(await c.req.json());
67
71
  try {
68
- return c.json(await createSocialPost(db, {
69
- accountId: grant.accountId,
70
- workspaceId,
71
- connectionId: payload.connectionId,
72
- externalPostId: payload.externalPostId ?? null,
73
- url: payload.url ?? null,
74
- authorHandle: payload.authorHandle ?? null,
75
- text: payload.text,
76
- publishedAt: new Date(payload.publishedAt),
77
- metrics: payload.metrics,
78
- raw: payload.raw,
79
- }), 201);
72
+ return c.json(
73
+ await createSocialPost(db, {
74
+ accountId: grant.accountId,
75
+ workspaceId,
76
+ connectionId: payload.connectionId,
77
+ externalPostId: payload.externalPostId ?? null,
78
+ url: payload.url ?? null,
79
+ authorHandle: payload.authorHandle ?? null,
80
+ text: payload.text,
81
+ publishedAt: new Date(payload.publishedAt),
82
+ metrics: payload.metrics,
83
+ raw: payload.raw,
84
+ }),
85
+ 201,
86
+ );
80
87
  } catch (error) {
81
88
  throw socialHttpException(error);
82
89
  }
@@ -98,10 +105,15 @@ function parseConnectionIds(raw: string | undefined): string[] | undefined {
98
105
  if (!raw) {
99
106
  return undefined;
100
107
  }
101
- const values = raw.split(",").map((value) => value.trim()).filter(Boolean);
108
+ const values = raw
109
+ .split(",")
110
+ .map((value) => value.trim())
111
+ .filter(Boolean);
102
112
  const parsed = z.array(z.string().uuid()).safeParse(values);
103
113
  if (!parsed.success) {
104
- throw new HTTPException(422, { message: "connectionIds must be a comma-separated list of UUIDs" });
114
+ throw new HTTPException(422, {
115
+ message: "connectionIds must be a comma-separated list of UUIDs",
116
+ });
105
117
  }
106
118
  const ids = parsed.data;
107
119
  return [...new Set(ids)];
@@ -0,0 +1,238 @@
1
+ // Workbench v2 — capture READ serving (dossier §10.3).
2
+ //
3
+ // The two GET capture routes in `sessions.ts` are thin: grant-first (files:read),
4
+ // load the row (DB, RLS-scoped), then delegate the response SHAPING to the two
5
+ // pure functions here. Keeping the shaping decoupled from Hono + the DB lets the
6
+ // hermetic route tests exercise every branch ({available:false}, inline-vs-signed
7
+ // manifest, file resolve/marker/404) with in-memory fakes — no live stack.
8
+ //
9
+ // These functions NEVER touch a live sandbox box: a capture is served entirely
10
+ // from the durable `workspace_captures` row + its object-storage blobs. That is
11
+ // the whole point — the <200ms cold paint must not depend on a warm machine.
12
+
13
+ import {
14
+ GetWorkspaceCaptureFileResponse,
15
+ GetWorkspaceCaptureResponse,
16
+ WorkspaceCaptureDegradedReason,
17
+ WorkspaceCaptureManifest,
18
+ WorkspaceCaptureStats,
19
+ } from "@opengeni/contracts";
20
+ import type { WorkspaceCaptureRow } from "@opengeni/db";
21
+ import { HTTPException } from "hono/http-exception";
22
+
23
+ // Serve the manifest inline below this size (the overwhelmingly common case —
24
+ // the one API round-trip requirement); above it, a signed GET URL to the blob.
25
+ export const CAPTURE_INLINE_MANIFEST_MAX_BYTES = 2 * 1024 * 1024;
26
+ // Serve a single after-image inline below this size; above it, a signed GET URL.
27
+ export const CAPTURE_INLINE_FILE_MAX_BYTES = 256 * 1024;
28
+ // Short-lived — the client fetches immediately after the metadata response.
29
+ export const CAPTURE_SIGNED_URL_TTL_SECONDS = 300;
30
+
31
+ // The slice of ObjectStorage the serving path needs. Structural so the tests can
32
+ // inject an in-memory map without standing up S3/minio.
33
+ export type CaptureStoragePort = {
34
+ getObjectBytes: (key: string) => Promise<{ bytes: Uint8Array } | null>;
35
+ createGetUrl: (args: {
36
+ key: string;
37
+ expiresInSeconds?: number;
38
+ }) => Promise<{ url: string; expiresAt: Date }>;
39
+ };
40
+
41
+ function signedUrl(signed: { url: string; expiresAt: Date }): { url: string; expiresAt: string } {
42
+ return { url: signed.url, expiresAt: signed.expiresAt.toISOString() };
43
+ }
44
+
45
+ // Fetch + validate the manifest blob for a row. Returns null when the row has no
46
+ // manifest key, the blob is gone (GC'd), or the bytes fail to parse/validate — a
47
+ // malformed capture is treated as "no capture available" (the list route degrades
48
+ // to {available:false}, the file route to 404). Capture reads must NEVER be worse
49
+ // than the status-quo live/wake fallback (dossier §10.10), so a poison row can
50
+ // never 500 the workbench; it degrades and logs.
51
+ async function loadManifest(
52
+ row: WorkspaceCaptureRow,
53
+ storage: CaptureStoragePort,
54
+ ): Promise<{ manifest: WorkspaceCaptureManifest; byteLength: number } | null> {
55
+ if (!row.manifestKey) return null;
56
+ const blob = await storage.getObjectBytes(row.manifestKey);
57
+ if (!blob) return null;
58
+ let json: unknown;
59
+ try {
60
+ json = JSON.parse(new TextDecoder().decode(blob.bytes));
61
+ } catch {
62
+ console.warn(
63
+ `workspace capture read — manifest blob is not valid JSON (session=${row.sessionId} rev=${row.revision})`,
64
+ );
65
+ return null;
66
+ }
67
+ const parsed = WorkspaceCaptureManifest.safeParse(json);
68
+ if (!parsed.success) {
69
+ console.warn(
70
+ `workspace capture read — manifest failed schema validation (session=${row.sessionId} rev=${row.revision})`,
71
+ );
72
+ return null;
73
+ }
74
+ return { manifest: parsed.data, byteLength: blob.bytes.byteLength };
75
+ }
76
+
77
+ /**
78
+ * Shape the GET …/workspace/capture response from a loaded row. `{available:false}`
79
+ * when there is no capture yet, the row is not in the `available` state, or its
80
+ * manifest blob has been GC'd (all graceful cold-fallback states — never errors).
81
+ * Inline manifest for ≤2MB, signed URL above.
82
+ */
83
+ export async function serveWorkspaceCapture(
84
+ row: WorkspaceCaptureRow | null,
85
+ storage: CaptureStoragePort,
86
+ ): Promise<GetWorkspaceCaptureResponse> {
87
+ if (!row) {
88
+ return { available: false };
89
+ }
90
+ if (row.state === "failed") {
91
+ const reason = WorkspaceCaptureDegradedReason.safeParse(row.stats.degradedReason);
92
+ if (!reason.success) {
93
+ // `failed` was reserved before repository-discovery markers existed. Do
94
+ // not invent a cause for an older or malformed row; plain unavailable is
95
+ // the only truthful backwards-compatible response.
96
+ return { available: false };
97
+ }
98
+ return GetWorkspaceCaptureResponse.parse({
99
+ available: false,
100
+ degradedReason: reason.data,
101
+ revision: row.revision,
102
+ capturedAt: row.capturedAt,
103
+ turnId: row.turnId,
104
+ leaseEpoch: row.leaseEpoch,
105
+ });
106
+ }
107
+ if (row.state !== "available" || !row.manifestKey) return { available: false };
108
+ const stats = WorkspaceCaptureStats.safeParse(row.stats);
109
+ if (!stats.success) {
110
+ // A row with malformed stats (or a synthetic/partial row) degrades to the
111
+ // cold-fallback state rather than 500.
112
+ console.warn(
113
+ `workspace capture read — row stats failed schema validation (session=${row.sessionId} rev=${row.revision})`,
114
+ );
115
+ return { available: false };
116
+ }
117
+ const meta = {
118
+ available: true as const,
119
+ revision: row.revision,
120
+ capturedAt: row.capturedAt,
121
+ turnId: row.turnId,
122
+ leaseEpoch: row.leaseEpoch,
123
+ sizeBytes: row.sizeBytes ?? 0,
124
+ stats: stats.data,
125
+ };
126
+ const blob = await storage.getObjectBytes(row.manifestKey);
127
+ if (!blob) {
128
+ // Manifest raced GC between the row read and the blob fetch — degrade to the
129
+ // cold-fallback state rather than 500.
130
+ return { available: false };
131
+ }
132
+ if (blob.bytes.byteLength <= CAPTURE_INLINE_MANIFEST_MAX_BYTES) {
133
+ let json: unknown;
134
+ try {
135
+ json = JSON.parse(new TextDecoder().decode(blob.bytes));
136
+ } catch {
137
+ console.warn(
138
+ `workspace capture read — manifest blob is not valid JSON (session=${row.sessionId} rev=${row.revision})`,
139
+ );
140
+ return { available: false };
141
+ }
142
+ const manifest = WorkspaceCaptureManifest.safeParse(json);
143
+ if (!manifest.success) {
144
+ console.warn(
145
+ `workspace capture read — manifest failed schema validation (session=${row.sessionId} rev=${row.revision})`,
146
+ );
147
+ return { available: false };
148
+ }
149
+ return GetWorkspaceCaptureResponse.parse({
150
+ ...meta,
151
+ manifest: manifest.data,
152
+ manifestUrl: null,
153
+ });
154
+ }
155
+ const signed = await storage.createGetUrl({
156
+ key: row.manifestKey,
157
+ expiresInSeconds: CAPTURE_SIGNED_URL_TTL_SECONDS,
158
+ });
159
+ return GetWorkspaceCaptureResponse.parse({
160
+ ...meta,
161
+ manifest: null,
162
+ manifestUrl: signedUrl(signed),
163
+ });
164
+ }
165
+
166
+ /**
167
+ * Shape the GET …/workspace/capture/file response from a loaded row (the row
168
+ * already resolved to the requested revision, or the latest). Throws
169
+ * HTTPException(404) when there is no capture, the path is not in the manifest,
170
+ * or the file was deleted. Returns a metadata-only marker for a tooLarge file (or
171
+ * a captured file whose after-image blob is missing). Inline content for ≤256KB,
172
+ * signed URL above.
173
+ */
174
+ export async function serveWorkspaceCaptureFile(
175
+ row: WorkspaceCaptureRow | null,
176
+ path: string,
177
+ storage: CaptureStoragePort,
178
+ ): Promise<GetWorkspaceCaptureFileResponse> {
179
+ const loaded = row ? await loadManifest(row, storage) : null;
180
+ if (!loaded) {
181
+ throw new HTTPException(404, { message: "capture not found" });
182
+ }
183
+ const { manifest } = loaded;
184
+ const file = manifest.files.find((f) => f.path === path);
185
+ if (!file) {
186
+ throw new HTTPException(404, { message: "path not in capture" });
187
+ }
188
+ if (file.deleted) {
189
+ // Parity with fs/read on a deleted path.
190
+ throw new HTTPException(404, { message: "file was deleted" });
191
+ }
192
+ const base = {
193
+ path: file.path,
194
+ revision: manifest.revision,
195
+ status: file.status,
196
+ hash: file.hash,
197
+ baseHash: file.baseHash,
198
+ sizeBytes: file.sizeBytes,
199
+ isBinary: file.isBinary,
200
+ tooLarge: file.tooLarge,
201
+ };
202
+ if (file.tooLarge || !file.contentRef) {
203
+ // Marker: content was not captured (guard tripped) or the blob is unavailable.
204
+ return GetWorkspaceCaptureFileResponse.parse({
205
+ ...base,
206
+ encoding: null,
207
+ content: null,
208
+ contentUrl: null,
209
+ });
210
+ }
211
+ if (file.sizeBytes <= CAPTURE_INLINE_FILE_MAX_BYTES) {
212
+ const blob = await storage.getObjectBytes(file.contentRef);
213
+ if (!blob) {
214
+ // After-image GC'd out from under us → return the marker (client opens live).
215
+ return GetWorkspaceCaptureFileResponse.parse({
216
+ ...base,
217
+ encoding: null,
218
+ content: null,
219
+ contentUrl: null,
220
+ });
221
+ }
222
+ const encoding = file.isBinary ? "base64" : "utf8";
223
+ const content = file.isBinary
224
+ ? Buffer.from(blob.bytes).toString("base64")
225
+ : new TextDecoder().decode(blob.bytes);
226
+ return GetWorkspaceCaptureFileResponse.parse({ ...base, encoding, content, contentUrl: null });
227
+ }
228
+ const signed = await storage.createGetUrl({
229
+ key: file.contentRef,
230
+ expiresInSeconds: CAPTURE_SIGNED_URL_TTL_SECONDS,
231
+ });
232
+ return GetWorkspaceCaptureFileResponse.parse({
233
+ ...base,
234
+ encoding: null,
235
+ content: null,
236
+ contentUrl: signedUrl(signed),
237
+ });
238
+ }
@@ -2,8 +2,12 @@ import {
2
2
  AddWorkspaceMemberRequest,
3
3
  CreateWorkspaceRequest,
4
4
  ListWorkspaceMembersResponse,
5
+ SetWorkspaceDefaultRigRequest,
5
6
  UpdateWorkspaceMemberRequest,
7
+ UpdateWorkspaceModelPolicyRequest,
6
8
  UpdateWorkspaceRequest,
9
+ UpdateWorkspaceSettingsRequest,
10
+ WorkspaceInferenceControlRequest,
7
11
  Workspace,
8
12
  WorkspaceMember,
9
13
  type AccessContext,
@@ -16,20 +20,33 @@ import {
16
20
  createWorkspace,
17
21
  deleteWorkspace,
18
22
  getManagedUserByEmail,
23
+ getWorkspaceModelPolicy,
19
24
  grantWorkspaceAccess,
20
25
  listScheduledTasks,
21
26
  listWorkspaceMembers,
27
+ listWorkspaceControlEvents,
22
28
  listWorkspacesForSubject,
23
29
  removeWorkspaceMember,
24
30
  requireWorkspace,
31
+ getRig,
32
+ setWorkspaceDefaultRig,
25
33
  updateWorkspace,
34
+ updateWorkspaceSettings,
35
+ upsertWorkspaceModelPolicy,
26
36
  } from "@opengeni/db";
27
37
  import type { Hono } from "hono";
28
38
  import { HTTPException } from "hono/http-exception";
29
39
  import { hasPermission, requireAccessContext, requireAccessGrant } from "@opengeni/core";
30
40
  import { requireLimit } from "@opengeni/core";
31
41
  import type { ApiRouteDeps } from "@opengeni/core";
32
- import { assertWorkspaceDeletable, assertWorkspaceMemberRemovable, resolveMemberSubjectId } from "@opengeni/core";
42
+ import {
43
+ assertWorkspaceDeletable,
44
+ assertWorkspaceMemberRemovable,
45
+ controlHumanWorkspace,
46
+ resolveMemberSubjectId,
47
+ } from "@opengeni/core";
48
+ import { boundedLimit } from "../http/common";
49
+ import { sseWorkspaceControlStream } from "../http/sse";
33
50
 
34
51
  export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
35
52
  app.get("/v1/access/me", async (c) => {
@@ -38,14 +55,24 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
38
55
 
39
56
  app.get("/v1/workspaces", async (c) => {
40
57
  const context = await requireAccessContext(c, deps);
41
- const readableWorkspaceIds = [...new Set(context.workspaceGrants
42
- .filter((grant) => hasPermission(grant.permissions, "workspace:read"))
43
- .map((grant) => grant.workspaceId))];
58
+ const readableWorkspaceIds = [
59
+ ...new Set(
60
+ context.workspaceGrants
61
+ .filter((grant) => hasPermission(grant.permissions, "workspace:read"))
62
+ .map((grant) => grant.workspaceId),
63
+ ),
64
+ ];
44
65
  if (readableWorkspaceIds.length > 0) {
45
- const workspaces = await Promise.all(readableWorkspaceIds.map((workspaceId) => requireWorkspace(deps.db, workspaceId)));
66
+ const workspaces = await Promise.all(
67
+ readableWorkspaceIds.map((workspaceId) => requireWorkspace(deps.db, workspaceId)),
68
+ );
46
69
  return c.json(workspaces.map((workspace) => Workspace.parse(workspace)));
47
70
  }
48
- return c.json((await listWorkspacesForSubject(deps.db, context.subjectId)).map((workspace) => Workspace.parse(workspace)));
71
+ return c.json(
72
+ (await listWorkspacesForSubject(deps.db, context.subjectId)).map((workspace) =>
73
+ Workspace.parse(workspace),
74
+ ),
75
+ );
49
76
  });
50
77
 
51
78
  app.post("/v1/workspaces", async (c) => {
@@ -63,7 +90,9 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
63
90
  slug: payload.slug?.trim() || null,
64
91
  externalSource: payload.externalSource ?? null,
65
92
  externalId: payload.externalId ?? null,
66
- ...(payload.agentInstructions !== undefined ? { agentInstructions: normalizeAgentInstructions(payload.agentInstructions) } : {}),
93
+ ...(payload.agentInstructions !== undefined
94
+ ? { agentInstructions: normalizeAgentInstructions(payload.agentInstructions) }
95
+ : {}),
67
96
  });
68
97
  await grantWorkspaceAccess(deps.db, {
69
98
  accountId,
@@ -89,8 +118,101 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
89
118
  const workspace = await updateWorkspace(deps.db, workspaceId, {
90
119
  ...(payload.name !== undefined ? { name: payload.name.trim() } : {}),
91
120
  ...(payload.slug !== undefined ? { slug: payload.slug?.trim() || null } : {}),
92
- ...(payload.agentInstructions !== undefined ? { agentInstructions: normalizeAgentInstructions(payload.agentInstructions) } : {}),
121
+ ...(payload.agentInstructions !== undefined
122
+ ? { agentInstructions: normalizeAgentInstructions(payload.agentInstructions) }
123
+ : {}),
124
+ });
125
+ return c.json(Workspace.parse(workspace));
126
+ });
127
+
128
+ // Read is via GET /v1/workspaces/:workspaceId (Workspace.settings). This PATCH
129
+ // deep-merges (top-level) a settings patch, preserving unknown/future keys.
130
+ app.patch("/v1/workspaces/:workspaceId/settings", async (c) => {
131
+ const workspaceId = c.req.param("workspaceId");
132
+ await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
133
+ const parsed = UpdateWorkspaceSettingsRequest.safeParse(await c.req.json());
134
+ if (!parsed.success) {
135
+ throw new HTTPException(400, { message: "invalid workspace settings patch" });
136
+ }
137
+ const workspace = await updateWorkspaceSettings(deps.db, workspaceId, parsed.data);
138
+ return c.json(Workspace.parse(workspace));
139
+ });
140
+
141
+ // Per-workspace model/provider availability policy (the HARD blocker over
142
+ // which providers/models may serve a turn at all). Absent row reads as
143
+ // unrestricted {null, null}.
144
+ app.get("/v1/workspaces/:workspaceId/model-policy", async (c) => {
145
+ const workspaceId = c.req.param("workspaceId");
146
+ await requireAccessGrant(c, deps, workspaceId, "workspace:read");
147
+ const policy = await getWorkspaceModelPolicy(deps.db, workspaceId);
148
+ return c.json({
149
+ allowedProviders: policy?.allowedProviders ?? null,
150
+ allowedModels: policy?.allowedModels ?? null,
151
+ });
152
+ });
153
+
154
+ // Full replace (PUT, not merge): null/omitted = unrestricted for that
155
+ // dimension; an empty array is a valid explicit total block. Admin access —
156
+ // this decides whether turns can reach paid providers, so it is the same
157
+ // trust level as billing-affecting workspace settings.
158
+ app.put("/v1/workspaces/:workspaceId/model-policy", async (c) => {
159
+ const workspaceId = c.req.param("workspaceId");
160
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
161
+ const payload = UpdateWorkspaceModelPolicyRequest.parse(await c.req.json());
162
+ const policy = await upsertWorkspaceModelPolicy(deps.db, {
163
+ accountId: grant.accountId,
164
+ workspaceId,
165
+ allowedProviders: payload.allowedProviders ?? null,
166
+ allowedModels: payload.allowedModels ?? null,
93
167
  });
168
+ return c.json(policy);
169
+ });
170
+
171
+ app.post("/v1/workspaces/:workspaceId/inference-control", async (c) => {
172
+ const workspaceId = c.req.param("workspaceId");
173
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
174
+ const payload = WorkspaceInferenceControlRequest.parse(await c.req.json());
175
+ return c.json(
176
+ await controlHumanWorkspace(
177
+ { db: deps.db, bus: deps.bus, workflowClient: deps.workflowClient },
178
+ { accountId: grant.accountId, workspaceId, subjectId: grant.subjectId },
179
+ payload,
180
+ ),
181
+ );
182
+ });
183
+
184
+ app.get("/v1/workspaces/:workspaceId/control-events", async (c) => {
185
+ const workspaceId = c.req.param("workspaceId");
186
+ await requireAccessGrant(c, deps, workspaceId, "workspace:read");
187
+ const after = Math.max(0, Number.parseInt(c.req.query("after") ?? "0", 10) || 0);
188
+ return c.json(
189
+ await listWorkspaceControlEvents(
190
+ deps.db,
191
+ workspaceId,
192
+ after,
193
+ boundedLimit(c.req.query("limit")),
194
+ ),
195
+ );
196
+ });
197
+
198
+ app.get("/v1/workspaces/:workspaceId/control-events/stream", async (c) => {
199
+ const workspaceId = c.req.param("workspaceId");
200
+ await requireAccessGrant(c, deps, workspaceId, "workspace:read");
201
+ const after = Math.max(0, Number.parseInt(c.req.query("after") ?? "0", 10) || 0);
202
+ return await sseWorkspaceControlStream(deps.db, deps.bus, workspaceId, after, c.req.raw.signal);
203
+ });
204
+
205
+ app.put("/v1/workspaces/:workspaceId/default-rig", async (c) => {
206
+ const workspaceId = c.req.param("workspaceId");
207
+ await requireAccessGrant(c, deps, workspaceId, "rigs:manage");
208
+ const payload = SetWorkspaceDefaultRigRequest.parse(await c.req.json());
209
+ if (payload.rigId) {
210
+ const rig = await getRig(deps.db, workspaceId, payload.rigId);
211
+ if (!rig) {
212
+ throw new HTTPException(422, { message: `unknown rigId: ${payload.rigId}` });
213
+ }
214
+ }
215
+ const workspace = await setWorkspaceDefaultRig(deps.db, workspaceId, payload.rigId);
94
216
  return c.json(Workspace.parse(workspace));
95
217
  });
96
218
 
@@ -109,9 +231,13 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
109
231
  // Clean external Temporal state the FK cascade can't reach: every scheduled
110
232
  // task's schedule (best-effort, mirroring the scheduled-task delete path).
111
233
  const tasks = await listScheduledTasks(deps.db, workspaceId, 1000);
112
- await Promise.all(tasks.map((task) =>
113
- deps.workflowClient.deleteScheduledTaskSchedule({ temporalScheduleId: task.temporalScheduleId }).catch(() => undefined),
114
- ));
234
+ await Promise.all(
235
+ tasks.map((task) =>
236
+ deps.workflowClient
237
+ .deleteScheduledTaskSchedule({ temporalScheduleId: task.temporalScheduleId })
238
+ .catch(() => undefined),
239
+ ),
240
+ );
115
241
  await deleteWorkspace(deps.db, workspaceId);
116
242
  return c.body(null, 204);
117
243
  });
@@ -198,9 +324,16 @@ function normalizeAgentInstructions(value: string | null): string | null {
198
324
  return trimmed.length > 0 ? trimmed : null;
199
325
  }
200
326
 
201
- function requireAccountPermission(context: AccessContext, accountId: string, permission: Permission): void {
327
+ function requireAccountPermission(
328
+ context: AccessContext,
329
+ accountId: string,
330
+ permission: Permission,
331
+ ): void {
202
332
  const grant = context.accountGrants.find((candidate) => candidate.accountId === accountId);
203
- if (!grant || (!grant.permissions.includes(permission) && !grant.permissions.includes("account:admin"))) {
333
+ if (
334
+ !grant ||
335
+ (!grant.permissions.includes(permission) && !grant.permissions.includes("account:admin"))
336
+ ) {
204
337
  throw new HTTPException(403, { message: `missing permission: ${permission}` });
205
338
  }
206
339
  }
@@ -34,7 +34,10 @@ import type {
34
34
  } from "@opengeni/core";
35
35
 
36
36
  export class SandboxResumeError extends Error {
37
- constructor(message: string, readonly cause?: unknown) {
37
+ constructor(
38
+ message: string,
39
+ readonly cause?: unknown,
40
+ ) {
38
41
  super(message);
39
42
  this.name = "SandboxResumeError";
40
43
  }
@@ -57,7 +60,9 @@ export function createApiSandboxClient(settings: Settings): ApiSandboxClient | u
57
60
  * a live session for one in-process op. The caller drives exec/readFile and then
58
61
  * drops the handle (resume → use → drop); it does NOT own the box.
59
62
  */
60
- export function makeResumeBoxById(client: ApiSandboxClient | undefined): (input: ResumeBoxByIdInput) => Promise<ResumedSandboxSession> {
63
+ export function makeResumeBoxById(
64
+ client: ApiSandboxClient | undefined,
65
+ ): (input: ResumeBoxByIdInput) => Promise<ResumedSandboxSession> {
61
66
  return async ({ backend, resumeState }: ResumeBoxByIdInput): Promise<ResumedSandboxSession> => {
62
67
  if (!client) {
63
68
  throw new SandboxResumeError(
@@ -77,7 +82,10 @@ export function makeResumeBoxById(client: ApiSandboxClient | undefined): (input:
77
82
  let session: ApiSandboxSession;
78
83
  try {
79
84
  const state = await client.deserializeSessionState(resumeState);
80
- session = await client.resume(state);
85
+ // API-direct access borrows the live box. The lease remains its sole
86
+ // lifecycle owner, even when the serialized founding handle was owned.
87
+ // Clone instead of mutating the canonical resume envelope.
88
+ session = await client.resume({ ...state, ownsSandbox: false });
81
89
  } catch (error) {
82
90
  throw new SandboxResumeError(
83
91
  `Failed to resume sandbox box by id on backend "${backend}": ${error instanceof Error ? error.message : String(error)}`,
@@ -28,7 +28,11 @@
28
28
  // on its callout timeout). The bearer's `exp` caps the minted credential's life so a
29
29
  // revoked/expired enrollment cannot outlive its bearer.
30
30
 
31
- import { resolveEnrollmentSigningSecret, type NatsCalloutConfig, type Settings } from "@opengeni/config";
31
+ import {
32
+ resolveEnrollmentSigningSecret,
33
+ type NatsCalloutConfig,
34
+ type Settings,
35
+ } from "@opengeni/config";
32
36
  import { verifyEnrollmentBearer } from "@opengeni/contracts";
33
37
  import { getEnrollment, type Database } from "@opengeni/db";
34
38
  import {