@opengeni/api-router 0.11.2 → 0.12.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,243 @@
1
+ import {
2
+ ActivateWorkspaceInstructionPolicyRequest,
3
+ CreateWorkspaceInstructionPolicyDraftRequest,
4
+ ImportLegacyWorkspaceInstructionPolicyDraftRequest,
5
+ RollbackWorkspaceInstructionPolicyRequest,
6
+ WorkspaceInstructionPolicyActivationResponse,
7
+ WorkspaceInstructionPolicyConflictResponse,
8
+ WorkspaceInstructionPolicyDiffRequest,
9
+ WorkspaceInstructionPolicyDiffResponse,
10
+ WorkspaceInstructionPolicyListQuery,
11
+ WorkspaceInstructionPolicyListResponse,
12
+ WorkspaceInstructionPolicyRevision,
13
+ } from "@opengeni/contracts";
14
+ import { requireAccessGrant, type ApiRouteDeps } from "@opengeni/core";
15
+ import {
16
+ activateWorkspaceInstructionPolicyRevision,
17
+ createWorkspaceInstructionPolicyDraft,
18
+ diffWorkspaceInstructionPolicyRevisions,
19
+ getWorkspaceInstructionPolicyRevision,
20
+ importLegacyWorkspaceInstructionPolicyDraft,
21
+ listWorkspaceInstructionPolicyRevisions,
22
+ rollbackWorkspaceInstructionPolicyRevision,
23
+ WorkspaceInstructionPolicyConflictError,
24
+ WorkspaceInstructionPolicyInvalidOperationError,
25
+ WorkspaceInstructionPolicyLegacyUnavailableError,
26
+ WorkspaceInstructionPolicyNotFoundError,
27
+ } from "@opengeni/db";
28
+ import type { Context, Hono } from "hono";
29
+ import { HTTPException } from "hono/http-exception";
30
+ import { z } from "zod";
31
+
32
+ const WorkspaceInstructionPolicyRevisionId = z.string().uuid();
33
+
34
+ async function parseBody<S extends z.ZodType>(context: Context, schema: S): Promise<z.infer<S>> {
35
+ const parsed = schema.safeParse(await context.req.json().catch(() => null));
36
+ if (!parsed.success) {
37
+ throw new HTTPException(422, { message: "Invalid workspace instruction-policy request" });
38
+ }
39
+ return parsed.data;
40
+ }
41
+
42
+ function policyErrorResponse(context: Context, error: unknown): Response {
43
+ if (error instanceof WorkspaceInstructionPolicyConflictError) {
44
+ return context.json(
45
+ WorkspaceInstructionPolicyConflictResponse.parse({
46
+ code: error.code,
47
+ message: error.message,
48
+ currentHead: error.currentHead,
49
+ }),
50
+ 409,
51
+ );
52
+ }
53
+ if (error instanceof WorkspaceInstructionPolicyNotFoundError) {
54
+ return context.json(
55
+ { code: "WORKSPACE_INSTRUCTION_POLICY_NOT_FOUND", message: error.message },
56
+ 404,
57
+ );
58
+ }
59
+ if (error instanceof WorkspaceInstructionPolicyLegacyUnavailableError) {
60
+ return context.json(
61
+ { code: "WORKSPACE_INSTRUCTION_POLICY_LEGACY_UNAVAILABLE", message: error.message },
62
+ 409,
63
+ );
64
+ }
65
+ if (error instanceof WorkspaceInstructionPolicyInvalidOperationError) {
66
+ return context.json(
67
+ { code: "INVALID_WORKSPACE_INSTRUCTION_POLICY_OPERATION", message: error.message },
68
+ 422,
69
+ );
70
+ }
71
+ throw error;
72
+ }
73
+
74
+ function assertBoundedActor(subjectId: string): void {
75
+ if (subjectId.trim().length < 1 || subjectId.length > 1_024) {
76
+ throw new HTTPException(400, { message: "Workspace instruction-policy actor is invalid" });
77
+ }
78
+ }
79
+
80
+ function parseRevisionId(context: Context): string {
81
+ const parsed = WorkspaceInstructionPolicyRevisionId.safeParse(context.req.param("revisionId"));
82
+ if (!parsed.success) {
83
+ throw new HTTPException(422, { message: "Invalid workspace instruction-policy revision id" });
84
+ }
85
+ return parsed.data;
86
+ }
87
+
88
+ export function registerWorkspaceInstructionPolicyRoutes(app: Hono, deps: ApiRouteDeps): void {
89
+ const base = "/v1/workspaces/:workspaceId/instruction-policies";
90
+
91
+ app.get(base, async (context) => {
92
+ const workspaceId = context.req.param("workspaceId");
93
+ await requireAccessGrant(context, deps, workspaceId, "workspace:read");
94
+ const parsed = WorkspaceInstructionPolicyListQuery.safeParse({
95
+ kind: context.req.query("kind"),
96
+ scope: context.req.query("scope"),
97
+ roleKey: context.req.query("roleKey"),
98
+ afterRevision: context.req.query("afterRevision"),
99
+ limit: context.req.query("limit"),
100
+ });
101
+ if (!parsed.success) {
102
+ throw new HTTPException(422, { message: "Invalid workspace instruction-policy query" });
103
+ }
104
+ return context.json(
105
+ WorkspaceInstructionPolicyListResponse.parse(
106
+ await listWorkspaceInstructionPolicyRevisions(deps.db, workspaceId, parsed.data),
107
+ ),
108
+ );
109
+ });
110
+
111
+ app.post(`${base}/drafts`, async (context) => {
112
+ const workspaceId = context.req.param("workspaceId");
113
+ const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:admin");
114
+ assertBoundedActor(grant.subjectId);
115
+ const request = await parseBody(context, CreateWorkspaceInstructionPolicyDraftRequest);
116
+ try {
117
+ return context.json(
118
+ WorkspaceInstructionPolicyRevision.parse(
119
+ await createWorkspaceInstructionPolicyDraft(deps.db, {
120
+ accountId: grant.accountId,
121
+ workspaceId,
122
+ createdBySubjectId: grant.subjectId,
123
+ kind: request.kind,
124
+ scope: request.scope,
125
+ roleKey: request.roleKey,
126
+ content: request.content,
127
+ provenanceSource: request.provenanceSource,
128
+ provenanceSourceId: request.provenanceSourceId,
129
+ supersedesRevisionId: request.supersedesRevisionId,
130
+ }),
131
+ ),
132
+ 201,
133
+ );
134
+ } catch (error) {
135
+ return policyErrorResponse(context, error);
136
+ }
137
+ });
138
+
139
+ app.post(`${base}/import-legacy`, async (context) => {
140
+ const workspaceId = context.req.param("workspaceId");
141
+ const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:admin");
142
+ assertBoundedActor(grant.subjectId);
143
+ const request = await parseBody(context, ImportLegacyWorkspaceInstructionPolicyDraftRequest);
144
+ try {
145
+ return context.json(
146
+ WorkspaceInstructionPolicyRevision.parse(
147
+ await importLegacyWorkspaceInstructionPolicyDraft(deps.db, {
148
+ accountId: grant.accountId,
149
+ workspaceId,
150
+ createdBySubjectId: grant.subjectId,
151
+ supersedesRevisionId: request.supersedesRevisionId,
152
+ }),
153
+ ),
154
+ 201,
155
+ );
156
+ } catch (error) {
157
+ return policyErrorResponse(context, error);
158
+ }
159
+ });
160
+
161
+ app.get(`${base}/diff`, async (context) => {
162
+ const workspaceId = context.req.param("workspaceId");
163
+ await requireAccessGrant(context, deps, workspaceId, "workspace:read");
164
+ const parsed = WorkspaceInstructionPolicyDiffRequest.safeParse({
165
+ fromRevisionId: context.req.query("fromRevisionId"),
166
+ toRevisionId: context.req.query("toRevisionId"),
167
+ });
168
+ if (!parsed.success) {
169
+ throw new HTTPException(422, { message: "Invalid workspace instruction-policy diff query" });
170
+ }
171
+ try {
172
+ return context.json(
173
+ WorkspaceInstructionPolicyDiffResponse.parse(
174
+ await diffWorkspaceInstructionPolicyRevisions(deps.db, workspaceId, parsed.data),
175
+ ),
176
+ );
177
+ } catch (error) {
178
+ return policyErrorResponse(context, error);
179
+ }
180
+ });
181
+
182
+ app.post(`${base}/rollback`, async (context) => {
183
+ const workspaceId = context.req.param("workspaceId");
184
+ const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:admin");
185
+ assertBoundedActor(grant.subjectId);
186
+ const request = await parseBody(context, RollbackWorkspaceInstructionPolicyRequest);
187
+ try {
188
+ return context.json(
189
+ WorkspaceInstructionPolicyActivationResponse.parse(
190
+ await rollbackWorkspaceInstructionPolicyRevision(deps.db, {
191
+ accountId: grant.accountId,
192
+ workspaceId,
193
+ targetRevisionId: request.targetRevisionId,
194
+ expectedCurrentRevisionId: request.expectedCurrentRevisionId,
195
+ actorSubjectId: grant.subjectId,
196
+ reason: request.reason,
197
+ }),
198
+ ),
199
+ );
200
+ } catch (error) {
201
+ return policyErrorResponse(context, error);
202
+ }
203
+ });
204
+
205
+ app.get(`${base}/:revisionId`, async (context) => {
206
+ const workspaceId = context.req.param("workspaceId");
207
+ await requireAccessGrant(context, deps, workspaceId, "workspace:read");
208
+ const revisionId = parseRevisionId(context);
209
+ try {
210
+ return context.json(
211
+ WorkspaceInstructionPolicyRevision.parse(
212
+ await getWorkspaceInstructionPolicyRevision(deps.db, workspaceId, revisionId),
213
+ ),
214
+ );
215
+ } catch (error) {
216
+ return policyErrorResponse(context, error);
217
+ }
218
+ });
219
+
220
+ app.post(`${base}/:revisionId/activate`, async (context) => {
221
+ const workspaceId = context.req.param("workspaceId");
222
+ const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:admin");
223
+ assertBoundedActor(grant.subjectId);
224
+ const revisionId = parseRevisionId(context);
225
+ const request = await parseBody(context, ActivateWorkspaceInstructionPolicyRequest);
226
+ try {
227
+ return context.json(
228
+ WorkspaceInstructionPolicyActivationResponse.parse(
229
+ await activateWorkspaceInstructionPolicyRevision(deps.db, {
230
+ accountId: grant.accountId,
231
+ workspaceId,
232
+ revisionId,
233
+ expectedCurrentRevisionId: request.expectedCurrentRevisionId,
234
+ actorSubjectId: grant.subjectId,
235
+ reason: request.reason,
236
+ }),
237
+ ),
238
+ );
239
+ } catch (error) {
240
+ return policyErrorResponse(context, error);
241
+ }
242
+ });
243
+ }
@@ -3,16 +3,16 @@
3
3
  // The structured services (FileSystem / Git / Terminal) are SYNCHRONOUS point
4
4
  // queries served client -> API -> box IN-PROCESS. Each call:
5
5
  //
6
- // 1. acquires an exact direct-request lease holder (warming the box when cold — the
7
- // same cold->warming CAS attachViewer runs; a Postgres txn the API OWNS),
8
- // 2. resumes the box BY ID from the group lease's resume_state envelope,
9
- // 3. builds ONE SandboxChannelAService around the live `session` handle,
10
- // 4. runs the op (fsList/gitDiff/ptyWrite/...), returns inline JSON,
11
- // 5. releases the viewer holder + drops the live handle.
6
+ // For a provider-backed home it acquires an exact direct-request lease holder,
7
+ // resumes the box by id, and releases the holder after the operation. For a
8
+ // Connected Machine home it follows the durable active pointer directly and
9
+ // uses its NATS request/reply control channel; it creates no phantom cloud lease.
10
+ // Both paths build one SandboxChannelAService, run the operation, and return the
11
+ // result inline.
12
12
  //
13
- // NO Temporal, NO worker RPC, NO NATS round-trip in this path reads never ride
14
- // the bus (which would corrupt SSE gap-fill). Only the side-effect NOTIFICATIONS
15
- // (fs.changed/git.changed/terminal.pty.*) ride A1 via appendAndPublishEvents.
13
+ // NO Temporal or worker RPC sits in this path. Provider-backed reads remain
14
+ // process-local; Connected Machine operations necessarily ride NATS. Side-effect
15
+ // notifications (fs.changed/git.changed/terminal.pty.*) ride A1.
16
16
  //
17
17
  // IMPORT DISCIPLINE: sandbox symbols come ONLY from @opengeni/runtime/sandbox
18
18
  // (the agent-loop-free leaf) — enforced by sandbox-access-import-guard.test.ts.
@@ -29,8 +29,10 @@ import type { Session } from "@opengeni/contracts";
29
29
  import {
30
30
  acquireLease,
31
31
  getSandboxSessionEnvelope,
32
+ getSandbox,
32
33
  loadWorkspaceEnvironmentForRun,
33
34
  markWarmLeaseInstanceLost,
35
+ readActiveSandbox,
34
36
  readLease,
35
37
  releaseLeaseHolder,
36
38
  type Database,
@@ -40,9 +42,11 @@ import { appendAndPublishEvents, type EventBus } from "@opengeni/events";
40
42
  import { HTTPException } from "hono/http-exception";
41
43
 
42
44
  import {
45
+ buildSelfhostedBackendSession,
43
46
  establishSandboxSessionFromEnvelope,
44
47
  isProviderSandboxNotFoundError,
45
48
  SandboxChannelAService,
49
+ NatsControlRpc,
46
50
  ChannelAConflictError,
47
51
  ChannelANotFoundError,
48
52
  ChannelAUnsupportedError,
@@ -55,7 +59,7 @@ import {
55
59
  type EstablishedSandboxSession,
56
60
  type RoutingSandboxSession,
57
61
  } from "@opengeni/runtime/sandbox";
58
- import { wrapChannelABoxWithRouting } from "@opengeni/core";
62
+ import { relayConfigFromSettings, wrapChannelABoxWithRouting } from "@opengeni/core";
59
63
  import { establishApiSandboxSpawner } from "./rematerialize";
60
64
 
61
65
  export type ChannelAServices = {
@@ -76,7 +80,9 @@ export type ChannelAContext = {
76
80
  // (for the pty exec-session epoch fence + revision seeding).
77
81
  export type ChannelAHandle = {
78
82
  service: SandboxChannelAService;
79
- lease: LeaseSnapshot;
83
+ /** Connected Machine homes deliberately have no cloud lease. Durable PTYs
84
+ * require a real home-provider lease and reject this null case. */
85
+ lease: LeaseSnapshot | null;
80
86
  routingSession: RoutingSandboxSession;
81
87
  requestId: string;
82
88
  };
@@ -107,6 +113,117 @@ export async function withChannelA<T>(
107
113
  const holderId = `direct:${requestId}`;
108
114
  const leaseTtlMs = settings.sandboxLeaseTtlMs;
109
115
 
116
+ // The STABLE run-environment used by both a cloud home and a machine home.
117
+ // It also carries the per-session Toolspace pointer selected below.
118
+ const workspaceEnvironment = await loadWorkspaceEnvironmentForRun(
119
+ db,
120
+ settings,
121
+ workspaceId,
122
+ session.environmentId,
123
+ );
124
+ const settingsForSession =
125
+ session.sandboxBackend !== settings.sandboxBackend
126
+ ? { ...settings, sandboxBackend: session.sandboxBackend }
127
+ : settings;
128
+ const environment = stableSandboxEnvironmentForRun(
129
+ settingsForSession,
130
+ workspaceEnvironment?.values ?? {},
131
+ { workspaceId },
132
+ );
133
+ if (hasGitCredentialRepositorySelection(session.resources)) {
134
+ applyGitAuthPointerEnvironment(
135
+ environment,
136
+ hasGitHubRepositorySelection(session.resources) ? githubAppBotIdentity(settings) : null,
137
+ );
138
+ }
139
+
140
+ const runEstablished = async (
141
+ routed: EstablishedSandboxSession,
142
+ lease: LeaseSnapshot | null,
143
+ ): Promise<T> => {
144
+ const emit = async (events: { type: string; payload: unknown }[]): Promise<void> => {
145
+ await appendAndPublishEvents(
146
+ db,
147
+ bus,
148
+ workspaceId,
149
+ session.id,
150
+ events.map((e) => ({ type: e.type as never, payload: e.payload })),
151
+ );
152
+ };
153
+ const routingSession = routed.session as RoutingSandboxSession;
154
+ const credentialSession = withRunCredentialsSession(routingSession as object, session.id);
155
+ const scopedSession = environment.OPENGENI_TOOLSPACE_TOKEN_FILE
156
+ ? withToolspaceTokenSession(
157
+ credentialSession,
158
+ toolspaceTokenFileFromEnvironment(environment, session.id),
159
+ )
160
+ : credentialSession;
161
+ const service = new SandboxChannelAService({
162
+ session: scopedSession as ChannelASession,
163
+ leaseEpoch: lease?.leaseEpoch ?? session.activeEpoch,
164
+ emit,
165
+ });
166
+ return await fn({ service, lease, routingSession, requestId });
167
+ };
168
+
169
+ // A machine-targeted top-level session has an honest selfhosted HOME label.
170
+ // It has no cloud provider box and therefore must not acquire or establish a
171
+ // phantom home lease before following its active machine pointer.
172
+ if (session.sandboxBackend === "selfhosted") {
173
+ let established: EstablishedSandboxSession | undefined;
174
+ try {
175
+ const pointer = await readActiveSandbox(db, workspaceId, session.id);
176
+ if (!pointer?.activeSandboxId) {
177
+ throw new HTTPException(409, {
178
+ message: "machine-home session has no active Connected Machine",
179
+ });
180
+ }
181
+ const sandbox = await getSandbox(db, workspaceId, pointer.activeSandboxId);
182
+ if (sandbox?.kind !== "selfhosted" || !sandbox.enrollmentId) {
183
+ throw new HTTPException(409, {
184
+ message: "machine-home session points to an unavailable Connected Machine",
185
+ });
186
+ }
187
+ const built = await buildSelfhostedBackendSession({
188
+ workspaceId,
189
+ agentId: sandbox.enrollmentId,
190
+ relay: relayConfigFromSettings(settings),
191
+ controlRpcFactory: () => new NatsControlRpc(async () => bus.getRequestConnection()),
192
+ epoch: pointer.activeEpoch,
193
+ environment,
194
+ workingDir: pointer.workingDir,
195
+ timeoutMs: settings.sandboxSelfhostedControlTimeoutMs,
196
+ execTimeoutMs: settings.sandboxSelfhostedExecTimeoutMs,
197
+ });
198
+ established = {
199
+ client: built.client,
200
+ session: built.session,
201
+ sessionState: { agentId: sandbox.enrollmentId },
202
+ instanceId: sandbox.enrollmentId,
203
+ backendId: "selfhosted",
204
+ };
205
+ const routed = wrapChannelABoxWithRouting(
206
+ { db, settings, bus },
207
+ {
208
+ accountId,
209
+ workspaceId,
210
+ sessionId: session.id,
211
+ pinnedSelfhosted: {
212
+ sandboxId: sandbox.id,
213
+ epoch: pointer.activeEpoch,
214
+ },
215
+ directRequest: { requestId, holderId },
216
+ },
217
+ established,
218
+ );
219
+ return await runEstablished(routed, null);
220
+ } catch (error) {
221
+ throw mapChannelAError(error);
222
+ } finally {
223
+ await dropEstablishedHandle(established);
224
+ }
225
+ }
226
+
110
227
  const release = async (): Promise<void> => {
111
228
  await releaseLeaseHolder(db, {
112
229
  accountId,
@@ -150,36 +267,6 @@ export async function withChannelA<T>(
150
267
 
151
268
  try {
152
269
  const envelope = await getSandboxSessionEnvelope(db, workspaceId, session.id);
153
- // The STABLE run-environment a COLD box must be created with so a later worker
154
- // turn's agent-manifest apply finds an EMPTY env delta (config base + git
155
- // identity + decrypted workspace env + HOME + — for a repo-attached session —
156
- // the stable git-auth pointers the turn declares). Only the rotating token
157
- // VALUE stays off (it lives in the box file the clone hook seeds). Keyed off
158
- // the SESSION's backend (the establish below passes backendOverride:
159
- // session.sandboxBackend, and HOME/token-file/askpass are backend-derived),
160
- // NOT the deployment default — mirrors sessionAttachEnvironment.
161
- const workspaceEnvironment = await loadWorkspaceEnvironmentForRun(
162
- db,
163
- settings,
164
- workspaceId,
165
- session.environmentId,
166
- );
167
- const settingsForSession =
168
- session.sandboxBackend !== settings.sandboxBackend
169
- ? { ...settings, sandboxBackend: session.sandboxBackend }
170
- : settings;
171
- const environment = stableSandboxEnvironmentForRun(
172
- settingsForSession,
173
- workspaceEnvironment?.values ?? {},
174
- { workspaceId },
175
- );
176
- if (hasGitCredentialRepositorySelection(session.resources)) {
177
- applyGitAuthPointerEnvironment(
178
- environment,
179
- hasGitHubRepositorySelection(session.resources) ? githubAppBotIdentity(settings) : null,
180
- );
181
- }
182
-
183
270
  if (acquired.role === "spawner") {
184
271
  // We won the cold->warming CAS: establish the box from the envelope, then
185
272
  // commit warm. The established handle IS our live handle for the op.
@@ -261,22 +348,10 @@ export async function withChannelA<T>(
261
348
  }
262
349
  }
263
350
 
264
- const emit = async (events: { type: string; payload: unknown }[]): Promise<void> => {
265
- await appendAndPublishEvents(
266
- db,
267
- bus,
268
- workspaceId,
269
- session.id,
270
- // SessionEventType is a string enum at the contract; the producer parses
271
- // the payload, so this cast is the same shape the worker emits.
272
- events.map((e) => ({ type: e.type as never, payload: e.payload })),
273
- );
274
- };
275
-
276
351
  // Route every call through the same proxy, even when hot-swap is disabled:
277
352
  // routing may be dormant, but its direct mutation admission is mandatory for
278
353
  // every persistable provider write.
279
- const routedSession = wrapChannelABoxWithRouting(
354
+ const routed = wrapChannelABoxWithRouting(
280
355
  { db, settings, bus },
281
356
  {
282
357
  accountId,
@@ -291,22 +366,8 @@ export async function withChannelA<T>(
291
366
  directRequest: { requestId, holderId },
292
367
  },
293
368
  established,
294
- ).session as RoutingSandboxSession;
295
- const credentialSession = withRunCredentialsSession(routedSession as object, session.id);
296
- const scopedSession = environment.OPENGENI_TOOLSPACE_TOKEN_FILE
297
- ? withToolspaceTokenSession(
298
- credentialSession,
299
- toolspaceTokenFileFromEnvironment(environment, session.id),
300
- )
301
- : credentialSession;
302
-
303
- const service = new SandboxChannelAService({
304
- session: scopedSession as ChannelASession,
305
- leaseEpoch: leaseSnapshot.leaseEpoch,
306
- emit,
307
- });
308
-
309
- return await fn({ service, lease: leaseSnapshot, routingSession: routedSession, requestId });
369
+ );
370
+ return await runEstablished(routed, leaseSnapshot);
310
371
  } catch (error) {
311
372
  throw mapChannelAError(error);
312
373
  } finally {