@opengeni/api-router 2.6.4-canary.0 → 2.8.1-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.
@@ -0,0 +1,231 @@
1
+ import {
2
+ CreateOrganizationProviderCustomModelRequest,
3
+ DeleteOrganizationProviderCustomModelRequest,
4
+ OrganizationModelProviderConnectionResponse,
5
+ OrganizationModelProviderKind,
6
+ OrganizationProviderCustomModel,
7
+ OrganizationProviderCustomModelsResponse,
8
+ RevokeOrganizationModelProviderConnectionRequest,
9
+ UpsertOrganizationModelProviderConnectionRequest,
10
+ } from "@opengeni/contracts";
11
+ import { requireEnvironmentEncryption, type ApiRouteDeps } from "@opengeni/core";
12
+ import {
13
+ createOrganizationModelProviderCustomModel,
14
+ encryptEnvironmentValue,
15
+ getOrganizationModelProviderConnection,
16
+ listOrganizationModelProviderCustomModels,
17
+ organizationModelProviderCredentialDigest,
18
+ OrganizationModelProviderConflictError,
19
+ OrganizationModelProviderLimitError,
20
+ retireOrganizationModelProviderCustomModel,
21
+ revokeOrganizationModelProviderConnection,
22
+ upsertOrganizationModelProviderConnection,
23
+ } from "@opengeni/db";
24
+ import type { Context, Hono } from "hono";
25
+ import { HTTPException } from "hono/http-exception";
26
+ import { z } from "zod";
27
+
28
+ import { requireOrganizationCodexHuman, requireSameOriginBrowserMutation } from "./codex";
29
+
30
+ const OrganizationId = z.string().uuid();
31
+
32
+ function parseOrganizationId(value: string): string {
33
+ const parsed = OrganizationId.safeParse(value);
34
+ if (!parsed.success) throw new HTTPException(404, { message: "organization not found" });
35
+ return parsed.data;
36
+ }
37
+
38
+ async function jsonBody<T>(c: Context, schema: z.ZodType<T>, message: string): Promise<T> {
39
+ const parsed = schema.safeParse(await c.req.json().catch(() => null));
40
+ if (!parsed.success) throw new HTTPException(422, { message });
41
+ return parsed.data;
42
+ }
43
+
44
+ function providerKind(value: string) {
45
+ const parsed = OrganizationModelProviderKind.safeParse(value);
46
+ if (!parsed.success) throw new HTTPException(404, { message: "model provider not found" });
47
+ return parsed.data;
48
+ }
49
+
50
+ function connectionJson(connection: {
51
+ providerKind: "vercel_gateway" | "openrouter";
52
+ status: "active" | "revoked";
53
+ version: number;
54
+ createdAt: Date;
55
+ updatedAt: Date;
56
+ }) {
57
+ return OrganizationModelProviderConnectionResponse.parse({
58
+ ...connection,
59
+ createdAt: connection.createdAt.toISOString(),
60
+ updatedAt: connection.updatedAt.toISOString(),
61
+ });
62
+ }
63
+
64
+ function modelJson(model: {
65
+ id: string;
66
+ upstreamModelId: string;
67
+ label: string | null;
68
+ version: number;
69
+ createdAt: Date;
70
+ updatedAt: Date;
71
+ }) {
72
+ return OrganizationProviderCustomModel.parse({
73
+ ...model,
74
+ createdAt: model.createdAt.toISOString(),
75
+ updatedAt: model.updatedAt.toISOString(),
76
+ });
77
+ }
78
+
79
+ function conflict(error: unknown): never {
80
+ if (error instanceof OrganizationModelProviderConflictError) {
81
+ throw new HTTPException(409, { message: "organization model provider version conflict" });
82
+ }
83
+ if (error instanceof OrganizationModelProviderLimitError) {
84
+ throw new HTTPException(409, { message: "organization custom model limit reached" });
85
+ }
86
+ throw error;
87
+ }
88
+
89
+ export function registerOrganizationModelProviderRoutes(app: Hono, deps: ApiRouteDeps): void {
90
+ app.get("/v1/organizations/:organizationId/model-providers/:providerKind", async (c) => {
91
+ c.header("cache-control", "private, no-store");
92
+ const organizationId = parseOrganizationId(c.req.param("organizationId"));
93
+ const human = await requireOrganizationCodexHuman(c, deps, organizationId);
94
+ const connection = await getOrganizationModelProviderConnection(deps.db, {
95
+ organizationId,
96
+ actorSubjectId: human.subjectId,
97
+ providerKind: providerKind(c.req.param("providerKind")),
98
+ });
99
+ return connection ? c.json(connectionJson(connection)) : c.json(null);
100
+ });
101
+
102
+ app.put("/v1/organizations/:organizationId/model-providers/:providerKind", async (c) => {
103
+ c.header("cache-control", "private, no-store");
104
+ requireSameOriginBrowserMutation(c, deps);
105
+ const organizationId = parseOrganizationId(c.req.param("organizationId"));
106
+ const human = await requireOrganizationCodexHuman(c, deps, organizationId);
107
+ const payload = await jsonBody(
108
+ c,
109
+ UpsertOrganizationModelProviderConnectionRequest,
110
+ "invalid organization model provider connection",
111
+ );
112
+ try {
113
+ const connection = await upsertOrganizationModelProviderConnection(deps.db, {
114
+ organizationId,
115
+ actorSubjectId: human.subjectId,
116
+ providerKind: providerKind(c.req.param("providerKind")),
117
+ credentialEncrypted: encryptEnvironmentValue(
118
+ requireEnvironmentEncryption(deps.settings),
119
+ payload.apiKey,
120
+ ),
121
+ credentialDigest: organizationModelProviderCredentialDigest(payload.apiKey),
122
+ operationId: payload.operationId,
123
+ ...(payload.expectedVersion === undefined
124
+ ? {}
125
+ : { expectedVersion: payload.expectedVersion }),
126
+ });
127
+ return c.json(connectionJson(connection));
128
+ } catch (error) {
129
+ conflict(error);
130
+ }
131
+ });
132
+
133
+ app.delete("/v1/organizations/:organizationId/model-providers/:providerKind", async (c) => {
134
+ c.header("cache-control", "private, no-store");
135
+ requireSameOriginBrowserMutation(c, deps);
136
+ const organizationId = parseOrganizationId(c.req.param("organizationId"));
137
+ const human = await requireOrganizationCodexHuman(c, deps, organizationId);
138
+ const payload = await jsonBody(
139
+ c,
140
+ RevokeOrganizationModelProviderConnectionRequest,
141
+ "invalid organization model provider disconnect",
142
+ );
143
+ try {
144
+ const connection = await revokeOrganizationModelProviderConnection(deps.db, {
145
+ organizationId,
146
+ actorSubjectId: human.subjectId,
147
+ providerKind: providerKind(c.req.param("providerKind")),
148
+ ...payload,
149
+ });
150
+ return c.json(connectionJson(connection));
151
+ } catch (error) {
152
+ conflict(error);
153
+ }
154
+ });
155
+
156
+ app.get(
157
+ "/v1/organizations/:organizationId/model-providers/:providerKind/custom-models",
158
+ async (c) => {
159
+ c.header("cache-control", "private, no-store");
160
+ const organizationId = parseOrganizationId(c.req.param("organizationId"));
161
+ const human = await requireOrganizationCodexHuman(c, deps, organizationId);
162
+ const models = await listOrganizationModelProviderCustomModels(deps.db, {
163
+ organizationId,
164
+ actorSubjectId: human.subjectId,
165
+ providerKind: providerKind(c.req.param("providerKind")),
166
+ });
167
+ return c.json(
168
+ OrganizationProviderCustomModelsResponse.parse({ models: models.map(modelJson) }),
169
+ );
170
+ },
171
+ );
172
+
173
+ app.post(
174
+ "/v1/organizations/:organizationId/model-providers/:providerKind/custom-models",
175
+ async (c) => {
176
+ c.header("cache-control", "private, no-store");
177
+ requireSameOriginBrowserMutation(c, deps);
178
+ const organizationId = parseOrganizationId(c.req.param("organizationId"));
179
+ const human = await requireOrganizationCodexHuman(c, deps, organizationId);
180
+ const payload = await jsonBody(
181
+ c,
182
+ CreateOrganizationProviderCustomModelRequest,
183
+ "invalid organization custom model",
184
+ );
185
+ try {
186
+ const model = await createOrganizationModelProviderCustomModel(deps.db, {
187
+ organizationId,
188
+ actorSubjectId: human.subjectId,
189
+ providerKind: providerKind(c.req.param("providerKind")),
190
+ operationId: payload.operationId,
191
+ upstreamModelId: payload.upstreamModelId,
192
+ ...(payload.label === undefined ? {} : { label: payload.label }),
193
+ });
194
+ return c.json(modelJson(model), 201);
195
+ } catch (error) {
196
+ conflict(error);
197
+ }
198
+ },
199
+ );
200
+
201
+ app.delete(
202
+ "/v1/organizations/:organizationId/model-providers/:providerKind/custom-models/:customModelId",
203
+ async (c) => {
204
+ c.header("cache-control", "private, no-store");
205
+ requireSameOriginBrowserMutation(c, deps);
206
+ const organizationId = parseOrganizationId(c.req.param("organizationId"));
207
+ const human = await requireOrganizationCodexHuman(c, deps, organizationId);
208
+ const payload = await jsonBody(
209
+ c,
210
+ DeleteOrganizationProviderCustomModelRequest,
211
+ "invalid organization custom model deletion",
212
+ );
213
+ const customModelId = z.string().uuid().safeParse(c.req.param("customModelId"));
214
+ if (!customModelId.success) {
215
+ throw new HTTPException(422, { message: "invalid organization custom model id" });
216
+ }
217
+ try {
218
+ const model = await retireOrganizationModelProviderCustomModel(deps.db, {
219
+ organizationId,
220
+ actorSubjectId: human.subjectId,
221
+ providerKind: providerKind(c.req.param("providerKind")),
222
+ customModelId: customModelId.data,
223
+ ...payload,
224
+ });
225
+ return c.json(modelJson(model));
226
+ } catch (error) {
227
+ conflict(error);
228
+ }
229
+ },
230
+ );
231
+ }
@@ -277,6 +277,7 @@ export function registerPackRoutes(app: Hono, deps: ApiRouteDeps): void {
277
277
  sourcePath: inline.sourcePath,
278
278
  name: inline.name,
279
279
  description: inline.description,
280
+ activationMode: inline.activationMode,
280
281
  contentSha256: inline.contentSha256,
281
282
  totalBytes: inline.totalBytes,
282
283
  files: inline.files,
@@ -3,10 +3,15 @@ import {
3
3
  ListPersonalGitHubRepositoriesResponse,
4
4
  PersonalGitHubConnectionStatusResponse,
5
5
  PersonalGitHubOAuthStartRequest,
6
+ PersonalGitHubRepositoryId,
6
7
  PersonalGitHubRepositorySelectionState,
7
8
  ReplacePersonalGitHubRepositorySelectionsRequest,
8
9
  VerifyPersonalGitHubRepositorySelectionsRequest,
9
10
  } from "@opengeni/contracts/personal-github";
11
+ import {
12
+ ListGitHubRepositoryBranchesQuery,
13
+ GitHubRepositoryBranchesResponse,
14
+ } from "@opengeni/contracts/github-repository-contracts";
10
15
  import {
11
16
  requireAccessGrant,
12
17
  requireAccessGrantAuthorization,
@@ -31,6 +36,7 @@ import {
31
36
  startPersonalGitHubOAuth,
32
37
  } from "../integrations/personal-github";
33
38
  import {
39
+ listLivePersonalGitHubRepositoryBranches,
34
40
  listLivePersonalGitHubRepositories,
35
41
  personalGitHubRepositoryProviderHttpError,
36
42
  PersonalGitHubRepositoryProviderError,
@@ -187,6 +193,39 @@ export function registerPersonalGitHubRoutes(app: Hono, deps: ApiRouteDeps): voi
187
193
  },
188
194
  );
189
195
 
196
+ app.get(
197
+ "/v1/workspaces/:workspaceId/connections/:connectionId/github/repositories/:repositoryId/branches",
198
+ async (c) => {
199
+ const workspaceId = c.req.param("workspaceId");
200
+ const connectionId = c.req.param("connectionId");
201
+ const access = await requireAccessGrantAuthorization(
202
+ c,
203
+ deps,
204
+ workspaceId,
205
+ "connections:read",
206
+ );
207
+ assertPersonalConnectionOwnerPrincipal(access, "My GitHub repositories");
208
+ const query = ListGitHubRepositoryBranchesQuery.parse(c.req.query());
209
+ const repositoryId = PersonalGitHubRepositoryId.parse(c.req.param("repositoryId"));
210
+ try {
211
+ return c.json(
212
+ GitHubRepositoryBranchesResponse.parse(
213
+ await listLivePersonalGitHubRepositoryBranches(deps, {
214
+ accountId: access.grant.accountId,
215
+ workspaceId,
216
+ subjectId: access.grant.subjectId,
217
+ connectionId,
218
+ repositoryId,
219
+ query,
220
+ }),
221
+ ),
222
+ );
223
+ } catch (error) {
224
+ throw personalGitHubRepositoryRouteError(error);
225
+ }
226
+ },
227
+ );
228
+
190
229
  app.post(
191
230
  "/v1/workspaces/:workspaceId/connections/:connectionId/github/repositories/verify",
192
231
  async (c) => {
@@ -14,6 +14,7 @@ import {
14
14
  defaultPrReviewProviderBaseUrl,
15
15
  getCapabilityPack,
16
16
  workspaceCustomModelReference,
17
+ lockActiveCustomModelForAdmission,
17
18
  prReviewWebhookAuthKind,
18
19
  normalizePrReviewProviderBaseUrl,
19
20
  prReviewPackConnectorId,
@@ -34,8 +35,6 @@ import {
34
35
  getPackInstallation,
35
36
  listPrReviewAppRegistrations,
36
37
  listPrReviewRepositoryBindings,
37
- lockActiveWorkspaceGatewayCustomModelForAdmission,
38
- lockActiveWorkspaceOpenRouterCustomModelForAdmission,
39
38
  nestedPostgresSqlState,
40
39
  recordAuditEvent,
41
40
  updatePrReviewAppRegistration,
@@ -557,18 +556,11 @@ function prReviewCustomModelCommitGuard(input: {
557
556
  const reference = workspaceCustomModelReference(input.settings, input.modelId);
558
557
  if (!reference) return undefined;
559
558
  return async (tx): Promise<void> => {
560
- const active =
561
- reference.providerKind === "openrouter"
562
- ? await lockActiveWorkspaceOpenRouterCustomModelForAdmission(tx, {
563
- accountId: input.accountId,
564
- workspaceId: input.workspaceId,
565
- upstreamModelId: reference.upstreamModelId,
566
- })
567
- : await lockActiveWorkspaceGatewayCustomModelForAdmission(tx, {
568
- accountId: input.accountId,
569
- workspaceId: input.workspaceId,
570
- upstreamModelId: reference.upstreamModelId,
571
- });
559
+ const active = await lockActiveCustomModelForAdmission(tx, {
560
+ accountId: input.accountId,
561
+ workspaceId: input.workspaceId,
562
+ reference,
563
+ });
572
564
  if (!active) {
573
565
  throw new HTTPException(422, {
574
566
  message: `model is not available: ${input.modelId}`,
@@ -197,7 +197,7 @@ import {
197
197
  import {
198
198
  appendAndPublishEvents,
199
199
  boundSessionEventHttpPage,
200
- coalesceSessionEventDeltas,
200
+ coalesceSessionEventDeltasWithCoverage,
201
201
  publishDurableSessionEvents,
202
202
  } from "@opengeni/events";
203
203
  import {
@@ -2621,10 +2621,16 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2621
2621
  c.header("X-OpenGeni-Covered-Last", String(result.coveredSequence.last));
2622
2622
  return c.json(result);
2623
2623
  }
2624
- const projected = compact ? coalesceSessionEventDeltas(events) : events;
2624
+ const compactProjection = compact ? coalesceSessionEventDeltasWithCoverage(events) : null;
2625
+ const projected = compactProjection?.events ?? events;
2626
+ const forensicExact =
2627
+ !compact && mode === "forensic" && payloadMode === "full" && dbPage.fullPayloadsExact;
2625
2628
  const page = boundSessionEventHttpPage(projected, {
2626
2629
  direction,
2627
- eventProjection: mode === "forensic" && payloadMode === "full" ? "exact" : "bounded",
2630
+ eventProjection: forensicExact ? "exact" : "bounded",
2631
+ ...(compactProjection
2632
+ ? { coveredThroughBySequence: compactProjection.coveredThroughBySequence }
2633
+ : {}),
2628
2634
  });
2629
2635
  const hasMore = dbPage.hasMore || page.truncated;
2630
2636
  c.header("X-OpenGeni-Page-Bytes", String(page.bytes));
@@ -2634,9 +2640,14 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2634
2640
  c.header("X-OpenGeni-Event-Mode", mode);
2635
2641
  c.header("X-OpenGeni-Event-Direction", direction);
2636
2642
  c.header("X-OpenGeni-Payload-Mode", payloadMode);
2637
- c.header("X-OpenGeni-Forensic-Exact", String(mode === "forensic" && payloadMode === "full"));
2643
+ c.header("X-OpenGeni-Forensic-Exact", String(forensicExact));
2638
2644
  const coveredFirst = page.events[0]?.sequence;
2639
- const coveredLast = page.events.at(-1)?.sequence;
2645
+ const coveredLastEvent = page.events.at(-1);
2646
+ const coveredLast =
2647
+ coveredLastEvent === undefined
2648
+ ? undefined
2649
+ : (compactProjection?.coveredThroughBySequence.get(coveredLastEvent.sequence) ??
2650
+ coveredLastEvent.sequence);
2640
2651
  if (coveredFirst !== undefined) c.header("X-OpenGeni-Covered-First", String(coveredFirst));
2641
2652
  if (coveredLast !== undefined) c.header("X-OpenGeni-Covered-Last", String(coveredLast));
2642
2653
  const truncatedBy = page.truncated ? "http_bytes" : dbPage.truncatedBy;
@@ -35,7 +35,6 @@ import {
35
35
  import {
36
36
  allWorkspacePermissions,
37
37
  createWorkspace,
38
- deleteWorkspaceIfQuiescent,
39
38
  ensureWorkspaceByExternalIdentity,
40
39
  findWorkspaceByExternalIdentity,
41
40
  getManagedUserProfilesByIds,
@@ -69,6 +68,8 @@ import {
69
68
  workspaceXaiSubscriptionActive,
70
69
  workspaceVercelAiGatewayConnectionActive,
71
70
  workspaceOpenRouterConnectionActive,
71
+ organizationModelProviderConnectionActiveForWorkspace,
72
+ listOrganizationModelProviderCustomModelsForWorkspace,
72
73
  WorkspaceGatewayCustomModelHistoryLimitError,
73
74
  WorkspaceOpenRouterCustomModelHistoryLimitError,
74
75
  WorkspaceExternalIdentityConflictError,
@@ -99,8 +100,7 @@ import { boundedLimit } from "../http/common";
99
100
  import { ApiHttpError } from "../http/api-error";
100
101
  import { browserSseDeliveryOptions, sseWorkspaceControlStream } from "../http/sse";
101
102
  import { buildWorkspaceModelCatalog } from "../model-catalog";
102
- import { processTemporalScheduleCleanupClaims } from "../temporal-schedule-cleanup";
103
- import { workspaceDeleteObserver } from "../workspace-delete-observability";
103
+ import { deleteWorkspaceForRequest } from "../workspace-deletion";
104
104
  import {
105
105
  AI_GATEWAY_REALTIME_MODELS,
106
106
  CODEX_REALTIME_MODEL_ID,
@@ -398,6 +398,10 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
398
398
  workspaceGatewayCustomModels,
399
399
  openRouterConnectionActive,
400
400
  workspaceOpenRouterCustomModels,
401
+ organizationGatewayConnectionActive,
402
+ organizationOpenRouterConnectionActive,
403
+ organizationGatewayCustomModels,
404
+ organizationOpenRouterCustomModels,
401
405
  ] = await Promise.all([
402
406
  deps.resolveCatalogSettings(),
403
407
  getWorkspaceModelPolicy(deps.db, workspaceId),
@@ -413,6 +417,26 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
413
417
  accountId: grant.accountId,
414
418
  workspaceId,
415
419
  }),
420
+ organizationModelProviderConnectionActiveForWorkspace(deps.db, {
421
+ accountId: grant.accountId,
422
+ workspaceId,
423
+ providerKind: "vercel_gateway",
424
+ }),
425
+ organizationModelProviderConnectionActiveForWorkspace(deps.db, {
426
+ accountId: grant.accountId,
427
+ workspaceId,
428
+ providerKind: "openrouter",
429
+ }),
430
+ listOrganizationModelProviderCustomModelsForWorkspace(deps.db, {
431
+ accountId: grant.accountId,
432
+ workspaceId,
433
+ providerKind: "vercel_gateway",
434
+ }),
435
+ listOrganizationModelProviderCustomModelsForWorkspace(deps.db, {
436
+ accountId: grant.accountId,
437
+ workspaceId,
438
+ providerKind: "openrouter",
439
+ }),
416
440
  ]);
417
441
  c.header("cache-control", "private, no-store");
418
442
  return c.json(
@@ -426,6 +450,10 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
426
450
  workspaceGatewayCustomModels,
427
451
  workspaceOpenRouterConnectionActive: openRouterConnectionActive,
428
452
  workspaceOpenRouterCustomModels,
453
+ organizationGatewayConnectionActive,
454
+ organizationOpenRouterConnectionActive,
455
+ organizationGatewayCustomModels,
456
+ organizationOpenRouterCustomModels,
429
457
  }),
430
458
  ),
431
459
  );
@@ -883,65 +911,10 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
883
911
  app.delete("/v1/workspaces/:workspaceId", async (c) => {
884
912
  const workspaceId = c.req.param("workspaceId");
885
913
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
886
- // The DB transaction locks the account/workspace and every existing
887
- // session/lease before checking runtime quiescence, then returns the exact
888
- // external schedules removed by the cascade. A racing cold->warm transition
889
- // can therefore never erase the only provider/capture ownership receipt.
890
- const deleteObserver = workspaceDeleteObserver(deps.observability, {
914
+ await deleteWorkspaceForRequest(deps, {
891
915
  accountId: grant.accountId,
892
916
  workspaceId,
893
917
  });
894
- const deleted = await deleteWorkspaceIfQuiescent(deps.db, {
895
- accountId: grant.accountId,
896
- workspaceId,
897
- ...(deleteObserver ? { observer: deleteObserver } : {}),
898
- });
899
- if (deleted.status === "not_found") {
900
- throw new HTTPException(404, { message: "workspace not found" });
901
- }
902
- if (deleted.status === "only_workspace") {
903
- throw new HTTPException(409, {
904
- message: "cannot delete the account's only workspace",
905
- });
906
- }
907
- if (deleted.status === "active_sessions") {
908
- throw new HTTPException(409, {
909
- message: "stop the workspace's running sessions before deleting it",
910
- });
911
- }
912
- if (deleted.status === "active_video_generations") {
913
- throw new HTTPException(409, {
914
- message: "wait for the workspace's active video generations to finish before deleting it",
915
- });
916
- }
917
- if (deleted.status === "active_background_commands") {
918
- throw new HTTPException(409, {
919
- message: "pause or cancel the workspace's background commands before deleting it",
920
- });
921
- }
922
- if (deleted.status === "live_sandboxes") {
923
- throw new HTTPException(409, {
924
- message: "wait for the workspace's active sandboxes to finish draining before deleting it",
925
- });
926
- }
927
- if (deleted.status !== "deleted") {
928
- throw new Error(`Unhandled workspace deletion outcome: ${deleted.status}`);
929
- }
930
- // The cleanup claims were inserted in the same transaction as the cascade.
931
- // Try them immediately; failures are released to the replica-safe outbox
932
- // pump, so a process crash or Temporal outage cannot orphan the schedules.
933
- await processTemporalScheduleCleanupClaims(
934
- {
935
- db: deps.db,
936
- deleteSchedule: async (temporalScheduleId) => {
937
- await deps.workflowClient.deleteScheduledTaskSchedule({
938
- temporalScheduleId,
939
- });
940
- },
941
- ...(deps.observability ? { observability: deps.observability } : {}),
942
- },
943
- deleted.temporalScheduleCleanups,
944
- );
945
918
  return c.body(null, 204);
946
919
  });
947
920
 
@@ -0,0 +1,64 @@
1
+ import { deleteWorkspaceIfQuiescent, nestedPostgresSqlState } from "@opengeni/db";
2
+ import type { ApiRouteDeps } from "@opengeni/core";
3
+ import { HTTPException } from "hono/http-exception";
4
+
5
+ import { processTemporalScheduleCleanupClaims } from "./temporal-schedule-cleanup";
6
+ import { workspaceDeleteObserver } from "./workspace-delete-observability";
7
+
8
+ export async function deleteWorkspaceForRequest(
9
+ deps: ApiRouteDeps,
10
+ input: {
11
+ accountId: string;
12
+ workspaceId: string;
13
+ organizationAdministratorSubjectId?: string;
14
+ },
15
+ ): Promise<void> {
16
+ const observer = workspaceDeleteObserver(deps.observability, input);
17
+ let deleted;
18
+ try {
19
+ deleted = await deleteWorkspaceIfQuiescent(deps.db, {
20
+ ...input,
21
+ ...(observer ? { observer } : {}),
22
+ });
23
+ } catch (error) {
24
+ const state = nestedPostgresSqlState(error);
25
+ if (state === "42501") {
26
+ throw new HTTPException(403, { message: "organization administration is not authorized" });
27
+ }
28
+ if (state === "P0002") {
29
+ throw new HTTPException(404, { message: "workspace not found" });
30
+ }
31
+ throw error;
32
+ }
33
+
34
+ if (deleted.status === "not_found") {
35
+ throw new HTTPException(404, { message: "workspace not found" });
36
+ }
37
+ const conflicts = {
38
+ only_workspace: "cannot delete the account's only workspace",
39
+ active_sessions: "stop the workspace's running sessions before deleting it",
40
+ active_video_generations:
41
+ "wait for the workspace's active video generations to finish before deleting it",
42
+ active_background_commands:
43
+ "pause or cancel the workspace's background commands before deleting it",
44
+ live_sandboxes:
45
+ "wait for the workspace's active sandboxes to finish draining before deleting it",
46
+ } as const;
47
+ if (deleted.status in conflicts) {
48
+ throw new HTTPException(409, { message: conflicts[deleted.status as keyof typeof conflicts] });
49
+ }
50
+ if (deleted.status !== "deleted") {
51
+ throw new Error(`Unhandled workspace deletion outcome: ${deleted.status}`);
52
+ }
53
+
54
+ await processTemporalScheduleCleanupClaims(
55
+ {
56
+ db: deps.db,
57
+ deleteSchedule: async (temporalScheduleId) => {
58
+ await deps.workflowClient.deleteScheduledTaskSchedule({ temporalScheduleId });
59
+ },
60
+ ...(deps.observability ? { observability: deps.observability } : {}),
61
+ },
62
+ deleted.temporalScheduleCleanups,
63
+ );
64
+ }