@opengeni/api-router 2.10.0-canary.0 → 2.10.0-canary.2

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.
@@ -1,3 +1,4 @@
1
+ import { scheduledSessionIds } from "@opengeni/db";
1
2
  import {
2
3
  AcknowledgeStreamRequest,
3
4
  ApplySessionGoalRevisionRequest,
@@ -335,6 +336,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
335
336
  db,
336
337
  settings,
337
338
  bus,
339
+ objectStorage,
338
340
  observability: deps.observability,
339
341
  };
340
342
  const workspaceCaptureManifestCache = new WorkspaceCaptureManifestCache();
@@ -516,6 +518,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
516
518
  db,
517
519
  settings,
518
520
  bus,
521
+ objectStorage,
519
522
  ...(deps.establishSandboxSession
520
523
  ? { establishSandboxSession: deps.establishSandboxSession }
521
524
  : {}),
@@ -666,6 +669,12 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
666
669
  ...(query.pinsOnly ? { pinsOnly: true } : {}),
667
670
  ...(query.archivedOnly ? { archivedOnly: true } : {}),
668
671
  ...(query.parentSessionId !== undefined ? { parentSessionId: query.parentSessionId } : {}),
672
+ ...(query.channelId !== undefined ? { channelId: query.channelId } : {}),
673
+ ...(query.createdBy ? { createdBy: query.createdBy } : {}),
674
+ ...(query.updatedFrom ? { updatedFrom: query.updatedFrom } : {}),
675
+ ...(query.updatedBefore ? { updatedBefore: query.updatedBefore } : {}),
676
+ ...(query.createdFrom ? { createdFrom: query.createdFrom } : {}),
677
+ ...(query.createdBefore ? { createdBefore: query.createdBefore } : {}),
669
678
  ...(authorizationScope ? { authorizationScope } : {}),
670
679
  // A managed human's own personal workspace has no membership row, so
671
680
  // the list's removal fence must fall back to the organization-membership
@@ -698,10 +707,23 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
698
707
  workspaceId,
699
708
  sessionIds: [...page.pinned, ...page.sessions].map((session) => session.id),
700
709
  });
710
+ const scheduleTargets =
711
+ hasPermission(grant.permissions, "scheduled_tasks:run") &&
712
+ hasPermission(grant.permissions, "sessions:control")
713
+ ? await scheduledSessionIds(
714
+ db,
715
+ workspaceId,
716
+ [...page.pinned, ...page.sessions].map((session) => session.id),
717
+ )
718
+ : new Set<string>();
701
719
  const decorate = (session: Session): Session => {
702
720
  const activity = commandActivity.get(session.id);
703
721
  return sessionWithEffectiveToolPolicy(
704
- { ...session, ...(activity ? { backgroundCommandActivity: activity } : {}) },
722
+ {
723
+ ...session,
724
+ hasSchedules: scheduleTargets.has(session.id),
725
+ ...(activity ? { backgroundCommandActivity: activity } : {}),
726
+ },
705
727
  policy.workspaceServerIds,
706
728
  policy.workspaceDefaultServerIds,
707
729
  );
@@ -709,6 +731,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
709
731
  if (pageView) {
710
732
  return c.json({
711
733
  ...page,
734
+ ...(query.hasPageFilters ? { filtersApplied: true as const } : {}),
712
735
  pinned: page.pinned.map(decorate),
713
736
  sessions: page.sessions.map(decorate),
714
737
  });
@@ -888,7 +911,23 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
888
911
  if (!session) {
889
912
  throw new HTTPException(404, { message: "session not found" });
890
913
  }
891
- return c.json(await withEffectivePolicy(deps, workspaceId, grant.subjectId, session));
914
+ const activity = await backgroundCommandActivityForSessions(db, {
915
+ accountId: grant.accountId,
916
+ workspaceId,
917
+ sessionIds: [sessionId],
918
+ });
919
+ const scheduleTargets =
920
+ hasPermission(grant.permissions, "scheduled_tasks:run") &&
921
+ hasPermission(grant.permissions, "sessions:control")
922
+ ? await scheduledSessionIds(db, workspaceId, [sessionId])
923
+ : new Set<string>();
924
+ return c.json(
925
+ await withEffectivePolicy(deps, workspaceId, grant.subjectId, {
926
+ ...session,
927
+ hasSchedules: scheduleTargets.has(sessionId),
928
+ ...(activity.get(sessionId) ? { backgroundCommandActivity: activity.get(sessionId) } : {}),
929
+ }),
930
+ );
892
931
  });
893
932
 
894
933
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/model-context", async (c) => {
@@ -925,6 +964,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
925
964
  accountId: grant.accountId,
926
965
  workspaceId,
927
966
  sessionId,
967
+ activeOnly: true,
928
968
  });
929
969
  return c.json({ commands });
930
970
  });
@@ -1803,17 +1843,40 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
1803
1843
  const workspaceId = c.req.param("workspaceId");
1804
1844
  const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:read");
1805
1845
  const lineage = await readSessionLineage(deps, grant, c.req.param("sessionId"));
1846
+ const sessionIds = [
1847
+ c.req.param("sessionId"),
1848
+ ...lineage.ancestors.map((session) => session.id),
1849
+ ];
1850
+ const collect = (nodes: LineageNode[]) => {
1851
+ for (const node of nodes) {
1852
+ sessionIds.push(node.session.id);
1853
+ collect(node.children);
1854
+ }
1855
+ };
1856
+ collect(lineage.children);
1857
+ const targets =
1858
+ hasPermission(grant.permissions, "scheduled_tasks:run") &&
1859
+ hasPermission(grant.permissions, "sessions:control")
1860
+ ? await scheduledSessionIds(db, workspaceId, sessionIds)
1861
+ : new Set<string>();
1862
+ const decorateNodes = (nodes: LineageNode[]): LineageNode[] =>
1863
+ nodes.map((node) => ({
1864
+ ...node,
1865
+ session: { ...node.session, hasSchedules: targets.has(node.session.id) },
1866
+ children: decorateNodes(node.children),
1867
+ }));
1806
1868
  const policy = await loadEffectivePolicyContext(deps, workspaceId, grant.subjectId);
1807
1869
  return c.json({
1808
1870
  ...lineage,
1871
+ sessionHasSchedules: targets.has(c.req.param("sessionId")),
1809
1872
  ancestors: lineage.ancestors.map((session) =>
1810
1873
  sessionWithEffectiveToolPolicy(
1811
- session,
1874
+ { ...session, hasSchedules: targets.has(session.id) },
1812
1875
  policy.workspaceServerIds,
1813
1876
  policy.workspaceDefaultServerIds,
1814
1877
  ),
1815
1878
  ),
1816
- children: mapLineageNodes(lineage.children, policy),
1879
+ children: decorateNodes(mapLineageNodes(lineage.children, policy)),
1817
1880
  });
1818
1881
  });
1819
1882
 
@@ -4617,6 +4680,13 @@ function sessionListQuery(
4617
4680
  search: string | undefined;
4618
4681
  pinsOnly: boolean;
4619
4682
  archivedOnly: boolean;
4683
+ channelId: string | null | undefined;
4684
+ createdBy: { kind: "subject" | "service"; subjectId: string } | undefined;
4685
+ updatedFrom: Date | undefined;
4686
+ updatedBefore: Date | undefined;
4687
+ createdFrom: Date | undefined;
4688
+ createdBefore: Date | undefined;
4689
+ hasPageFilters: boolean;
4620
4690
  } {
4621
4691
  const parentSessionId = query.parentSessionId;
4622
4692
  // "null" = roots only; a uuid = children of that session; anything else is
@@ -4652,12 +4722,73 @@ function sessionListQuery(
4652
4722
  throw new HTTPException(400, { message: 'archivedOnly must be the literal "true"' });
4653
4723
  }
4654
4724
  const archivedOnly = query.archivedOnly === "true";
4725
+ const channelId = query.channelId;
4726
+ if (
4727
+ channelId !== undefined &&
4728
+ channelId !== "null" &&
4729
+ !z.string().uuid().safeParse(channelId).success
4730
+ ) {
4731
+ throw new HTTPException(400, {
4732
+ message: 'channelId must be a channel id or the literal "null"',
4733
+ });
4734
+ }
4735
+ const createdByKind = query.createdByKind;
4736
+ const createdBySubjectId = query.createdBySubjectId;
4737
+ if ((createdByKind === undefined) !== (createdBySubjectId === undefined)) {
4738
+ throw new HTTPException(400, {
4739
+ message: "createdByKind and createdBySubjectId must be supplied together",
4740
+ });
4741
+ }
4742
+ if (createdByKind !== undefined && createdByKind !== "subject" && createdByKind !== "service") {
4743
+ throw new HTTPException(400, {
4744
+ message: 'createdByKind must be "subject" or "service"',
4745
+ });
4746
+ }
4747
+ if (
4748
+ createdBySubjectId !== undefined &&
4749
+ (createdBySubjectId.trim().length < 1 || createdBySubjectId.length > 1_024)
4750
+ ) {
4751
+ throw new HTTPException(400, {
4752
+ message: "createdBySubjectId must be between 1 and 1024 characters",
4753
+ });
4754
+ }
4755
+ const parseDateBound = (name: string): Date | undefined => {
4756
+ const raw = query[name];
4757
+ if (raw === undefined) return undefined;
4758
+ const parsed = z.string().datetime({ offset: true }).safeParse(raw);
4759
+ if (!parsed.success) {
4760
+ throw new HTTPException(400, { message: `${name} must be an ISO-8601 timestamp` });
4761
+ }
4762
+ // Date-backed filters must not silently round accepted microseconds to an
4763
+ // earlier boundary. Clients can use any ISO offset with at most 3 decimals.
4764
+ if (/\.\d{4,}/.test(parsed.data)) {
4765
+ throw new HTTPException(400, { message: `${name} supports at most millisecond precision` });
4766
+ }
4767
+ return new Date(parsed.data);
4768
+ };
4769
+ const updatedFrom = parseDateBound("updatedFrom");
4770
+ const updatedBefore = parseDateBound("updatedBefore");
4771
+ const createdFrom = parseDateBound("createdFrom");
4772
+ const createdBefore = parseDateBound("createdBefore");
4773
+ if (updatedFrom && updatedBefore && updatedFrom >= updatedBefore) {
4774
+ throw new HTTPException(400, { message: "updatedFrom must be earlier than updatedBefore" });
4775
+ }
4776
+ if (createdFrom && createdBefore && createdFrom >= createdBefore) {
4777
+ throw new HTTPException(400, { message: "createdFrom must be earlier than createdBefore" });
4778
+ }
4779
+ const hasPageFilters =
4780
+ channelId !== undefined ||
4781
+ createdByKind !== undefined ||
4782
+ updatedFrom !== undefined ||
4783
+ updatedBefore !== undefined ||
4784
+ createdFrom !== undefined ||
4785
+ createdBefore !== undefined;
4655
4786
  if (pinsOnly && !allowCursor) {
4656
4787
  throw new HTTPException(400, { message: 'pinsOnly requires view="page"' });
4657
4788
  }
4658
- if (pinsOnly && (rawCursor || parentSessionId !== undefined || search)) {
4789
+ if (pinsOnly && (rawCursor || parentSessionId !== undefined || search || hasPageFilters)) {
4659
4790
  throw new HTTPException(400, {
4660
- message: "pinsOnly cannot be combined with cursor, parentSessionId, or search",
4791
+ message: "pinsOnly cannot be combined with cursor, parentSessionId, search, or filters",
4661
4792
  });
4662
4793
  }
4663
4794
  if (pinsOnly && archivedOnly) {
@@ -4675,6 +4806,16 @@ function sessionListQuery(
4675
4806
  search: search || undefined,
4676
4807
  pinsOnly,
4677
4808
  archivedOnly,
4809
+ channelId: channelId === undefined ? undefined : channelId === "null" ? null : channelId,
4810
+ createdBy:
4811
+ createdByKind && createdBySubjectId
4812
+ ? { kind: createdByKind, subjectId: createdBySubjectId }
4813
+ : undefined,
4814
+ updatedFrom,
4815
+ updatedBefore,
4816
+ createdFrom,
4817
+ createdBefore,
4818
+ hasPageFilters,
4678
4819
  };
4679
4820
  }
4680
4821
 
@@ -4934,9 +5075,11 @@ export function agentTopologyQuery(query: Record<string, string>): {
4934
5075
  throw new HTTPException(400, { message: "query cannot be combined with an exact subject" });
4935
5076
  }
4936
5077
  const relevanceRequested = Boolean(searchQuery || subject);
5078
+ // An explicit root scopes the whole workstream; only an explicit parent
5079
+ // narrows it to one level. Ordinary browse still defaults to root sessions.
4937
5080
  const parentSessionId =
4938
5081
  rawParent === undefined
4939
- ? relevanceRequested
5082
+ ? relevanceRequested || Boolean(rootSessionId)
4940
5083
  ? undefined
4941
5084
  : null
4942
5085
  : rawParent === "null"
@@ -36,16 +36,20 @@ import {
36
36
  import type { Context, Hono } from "hono";
37
37
  import { HTTPException } from "hono/http-exception";
38
38
  import { z } from "zod";
39
- import {
40
- prepareWorkspaceArtifactContent,
41
- readWorkspaceArtifactContent,
42
- } from "../workspace-artifact-content";
39
+ import { readWorkspaceArtifactContent } from "../workspace-artifact-content";
43
40
  import {
44
41
  projectWorkspaceArtifactDetailProvenance,
45
42
  projectWorkspaceArtifactMutationProvenance,
46
43
  redactWorkspaceArtifactListProvenance,
47
44
  } from "../workspace-artifact-provenance";
48
45
 
46
+ import {
47
+ prepareWorkspaceArtifactPublication,
48
+ prepareWorkspaceArtifactUpload,
49
+ workspaceArtifactDownloads,
50
+ readArtifactObject,
51
+ } from "../site-uploads";
52
+
49
53
  const ArtifactId = z.string().uuid();
50
54
 
51
55
  async function body<S extends z.ZodType>(context: Context, schema: S): Promise<z.infer<S>> {
@@ -54,6 +58,13 @@ async function body<S extends z.ZodType>(context: Context, schema: S): Promise<z
54
58
  return parsed.data;
55
59
  }
56
60
 
61
+ function parseVersionId(value: string | undefined): string | undefined {
62
+ if (value !== undefined && !ArtifactId.safeParse(value).success) {
63
+ throw new HTTPException(422, { message: "Invalid artifact version id" });
64
+ }
65
+ return value;
66
+ }
67
+
57
68
  function artifactId(context: Context): string {
58
69
  const parsed = ArtifactId.safeParse(context.req.param("artifactId"));
59
70
  if (!parsed.success) throw new HTTPException(422, { message: "Invalid artifact id" });
@@ -94,14 +105,14 @@ export function workspaceArtifactErrorResponse(context: Context, error: unknown)
94
105
 
95
106
  function prepareContent(
96
107
  deps: ApiRouteDeps,
97
- workspaceId: string,
98
- input: Parameters<typeof prepareWorkspaceArtifactContent>[2],
108
+ grant: AccessGrant,
109
+ input: Parameters<typeof prepareWorkspaceArtifactPublication>[2],
99
110
  ) {
100
111
  if (!deps.objectStorage)
101
112
  throw new HTTPException(503, {
102
113
  message: "Object storage is not configured",
103
114
  });
104
- return prepareWorkspaceArtifactContent(deps.objectStorage, workspaceId, input);
115
+ return prepareWorkspaceArtifactPublication(deps, grant, input);
105
116
  }
106
117
 
107
118
  function provenance(subjectId: string, idempotencyKey: string) {
@@ -156,6 +167,30 @@ export function registerWorkspaceArtifactRoutes(app: Hono, deps: ApiRouteDeps):
156
167
  // retained-output API. The product route remains simply `/artifacts`.
157
168
  const base = "/v1/workspaces/:workspaceId/published-artifacts";
158
169
 
170
+ app.post(`${base}/uploads`, async (context) => {
171
+ const grant = await requireAccessGrant(
172
+ context,
173
+ deps,
174
+ context.req.param("workspaceId"),
175
+ "artifacts:publish",
176
+ );
177
+ return context.json(await prepareWorkspaceArtifactUpload(deps, grant));
178
+ });
179
+
180
+ app.get(`${base}/:artifactId/downloads`, async (context) => {
181
+ const workspaceId = context.req.param("workspaceId");
182
+ await requireAccessGrant(context, deps, workspaceId, "artifacts:read");
183
+ if (!deps.objectStorage)
184
+ throw new HTTPException(503, { message: "Object storage is not configured" });
185
+ const ref = await getWorkspaceArtifactContentRef(
186
+ deps.db,
187
+ workspaceId,
188
+ artifactId(context),
189
+ parseVersionId(context.req.query("versionId")),
190
+ );
191
+ return context.json(await workspaceArtifactDownloads(deps.objectStorage, ref, "public"));
192
+ });
193
+
159
194
  app.get(base, async (context) => {
160
195
  const workspaceId = context.req.param("workspaceId");
161
196
  await requireAccessGrant(context, deps, workspaceId, "artifacts:read");
@@ -195,12 +230,13 @@ export function registerWorkspaceArtifactRoutes(app: Hono, deps: ApiRouteDeps):
195
230
  const id = crypto.randomUUID();
196
231
  const slugBase = request.slug ?? (normalizeWorkspaceArtifactSlug(request.title) || "artifact");
197
232
  const slug = request.slug ?? `${slugBase.slice(0, 87)}-${id.slice(0, 8)}`;
198
- const content = prepareContent(deps, workspaceId, {
199
- html: request.html,
200
- ...(request.source ? { source: request.source } : {}),
201
- ...(request.requestedTools ? { requestedTools: request.requestedTools } : {}),
202
- });
203
233
  try {
234
+ const content = await prepareContent(deps, grant, {
235
+ ...(request.html !== undefined ? { html: request.html } : {}),
236
+ ...(request.uploadId ? { uploadId: request.uploadId } : {}),
237
+ ...(request.source ? { source: request.source } : {}),
238
+ ...(request.requestedTools ? { requestedTools: request.requestedTools } : {}),
239
+ });
204
240
  return context.json(
205
241
  await mutationResponse(
206
242
  deps,
@@ -241,6 +277,43 @@ export function registerWorkspaceArtifactRoutes(app: Hono, deps: ApiRouteDeps):
241
277
  }
242
278
  });
243
279
 
280
+ app.get(`${base}/:artifactId/html`, async (context) => {
281
+ const workspaceId = context.req.param("workspaceId");
282
+ await requireAccessGrant(context, deps, workspaceId, "artifacts:read");
283
+ if (!deps.objectStorage)
284
+ throw new HTTPException(503, { message: "Object storage is not configured" });
285
+ const ref = await getWorkspaceArtifactContentRef(
286
+ deps.db,
287
+ workspaceId,
288
+ artifactId(context),
289
+ parseVersionId(context.req.query("versionId")),
290
+ );
291
+ const iterator = readArtifactObject(deps.objectStorage, ref.contentKey);
292
+ return new Response(
293
+ new ReadableStream({
294
+ async pull(controller) {
295
+ try {
296
+ const item = await iterator.next();
297
+ if (item.done) controller.close();
298
+ else controller.enqueue(item.value);
299
+ } catch (error) {
300
+ controller.error(error);
301
+ }
302
+ },
303
+ async cancel() {
304
+ await iterator.return();
305
+ },
306
+ }),
307
+ {
308
+ headers: {
309
+ "Content-Type": "text/html; charset=utf-8",
310
+ "Cache-Control": "private, no-store",
311
+ "Content-Security-Policy": "sandbox allow-scripts",
312
+ },
313
+ },
314
+ );
315
+ });
316
+
244
317
  app.get(`${base}/:artifactId/content`, async (context) => {
245
318
  const workspaceId = context.req.param("workspaceId");
246
319
  await requireAccessGrant(context, deps, workspaceId, "artifacts:read");
@@ -248,10 +321,7 @@ export function registerWorkspaceArtifactRoutes(app: Hono, deps: ApiRouteDeps):
248
321
  throw new HTTPException(503, {
249
322
  message: "Object storage is not configured",
250
323
  });
251
- const parsedVersion = context.req.query("versionId");
252
- if (parsedVersion && !ArtifactId.safeParse(parsedVersion).success) {
253
- throw new HTTPException(422, { message: "Invalid artifact version id" });
254
- }
324
+ const parsedVersion = parseVersionId(context.req.query("versionId"));
255
325
  try {
256
326
  const ref = await getWorkspaceArtifactContentRef(
257
327
  deps.db,
@@ -293,12 +363,13 @@ export function registerWorkspaceArtifactRoutes(app: Hono, deps: ApiRouteDeps):
293
363
  const grant = authorization.grant;
294
364
  const request = await body(context, PublishWorkspaceArtifactVersionRequest);
295
365
  const id = artifactId(context);
296
- const content = prepareContent(deps, workspaceId, {
297
- html: request.html,
298
- ...(request.source ? { source: request.source } : {}),
299
- ...(request.requestedTools ? { requestedTools: request.requestedTools } : {}),
300
- });
301
366
  try {
367
+ const content = await prepareContent(deps, grant, {
368
+ ...(request.html !== undefined ? { html: request.html } : {}),
369
+ ...(request.uploadId ? { uploadId: request.uploadId } : {}),
370
+ ...(request.source ? { source: request.source } : {}),
371
+ ...(request.requestedTools ? { requestedTools: request.requestedTools } : {}),
372
+ });
302
373
  return context.json(
303
374
  await mutationResponse(
304
375
  deps,
@@ -55,6 +55,7 @@ import {
55
55
  } from "@opengeni/observability";
56
56
  import { HTTPException } from "hono/http-exception";
57
57
  import { ApiHttpError } from "../http/api-error";
58
+ import type { ObjectStorage } from "@opengeni/storage";
58
59
 
59
60
  import {
60
61
  buildSelfhostedBackendSession,
@@ -101,6 +102,7 @@ export type ChannelAServices = {
101
102
  db: Database;
102
103
  settings: Settings;
103
104
  bus: EventBus;
105
+ objectStorage?: ObjectStorage | null;
104
106
  observability?: Observability | undefined;
105
107
  };
106
108
 
@@ -1012,6 +1014,9 @@ async function withChannelAOperation<T>(
1012
1014
  acquiredLease: acquired.lease,
1013
1015
  fallbackEnvelope: envelope,
1014
1016
  dataPlaneUrl: acquired.lease.dataPlaneUrl,
1017
+ ...(services.objectStorage !== undefined
1018
+ ? { objectStorage: services.objectStorage }
1019
+ : {}),
1015
1020
  });
1016
1021
  established = result.established;
1017
1022
  leaseSnapshot = result.lease;
@@ -1,4 +1,5 @@
1
1
  import type { Settings } from "@opengeni/config";
2
+ import { parseWorkspaceArchiveObjectRef } from "@opengeni/contracts";
2
3
  import {
3
4
  adoptLegacyModalCheckpointArtifact,
4
5
  beginSandboxRematerialization,
@@ -13,6 +14,7 @@ import {
13
14
  type LeaseSnapshot,
14
15
  } from "@opengeni/db";
15
16
  import {
17
+ inlineWorkspaceArchiveForRestore,
16
18
  describeLegacyNativeSnapshotArchive,
17
19
  establishSandboxSessionFromEnvelope,
18
20
  isProviderSandboxNotFoundError,
@@ -30,17 +32,55 @@ import {
30
32
  type EstablishedSandboxSession,
31
33
  type WorkspaceArchiveDescriptor,
32
34
  } from "@opengeni/runtime/sandbox";
35
+ import type { ObjectStorage } from "@opengeni/storage";
33
36
 
34
37
  function hasWorkspaceArchive(envelope: Record<string, unknown> | null): boolean {
35
38
  const sessionState =
36
- envelope?.sessionState && typeof envelope.sessionState === "object"
39
+ envelope?.sessionState &&
40
+ typeof envelope.sessionState === "object" &&
41
+ !Array.isArray(envelope.sessionState)
37
42
  ? (envelope.sessionState as Record<string, unknown>)
38
43
  : null;
39
44
  return (
40
- typeof sessionState?.workspaceArchive === "string" && sessionState.workspaceArchive.length > 0
45
+ (typeof sessionState?.workspaceArchive === "string" &&
46
+ sessionState.workspaceArchive.length > 0) ||
47
+ parseWorkspaceArchiveObjectRef(sessionState?.workspaceArchiveRef) !== null
41
48
  );
42
49
  }
43
50
 
51
+ async function materializeArchiveObjectRef(
52
+ envelope: Record<string, unknown> | null,
53
+ objectStorage: ObjectStorage | null | undefined,
54
+ ): Promise<Record<string, unknown> | null> {
55
+ if (!envelope) return null;
56
+ const sessionState =
57
+ envelope.sessionState &&
58
+ typeof envelope.sessionState === "object" &&
59
+ !Array.isArray(envelope.sessionState)
60
+ ? (envelope.sessionState as Record<string, unknown>)
61
+ : null;
62
+ if (!sessionState) return envelope;
63
+ if (sessionState.workspaceArchiveRef == null) return envelope;
64
+ if (!parseWorkspaceArchiveObjectRef(sessionState.workspaceArchiveRef)) {
65
+ throw new WorkspaceArchiveIntegrityError(
66
+ "archive_metadata_invalid",
67
+ "workspace archive object reference is malformed",
68
+ );
69
+ }
70
+ if (!objectStorage) {
71
+ throw new WorkspaceArchiveIntegrityError(
72
+ "archive_base64_invalid",
73
+ "workspace archive object storage is not configured",
74
+ );
75
+ }
76
+ return {
77
+ ...envelope,
78
+ sessionState: await inlineWorkspaceArchiveForRestore(sessionState, (key) =>
79
+ objectStorage.getObjectBytes(key),
80
+ ),
81
+ };
82
+ }
83
+
44
84
  function legacyNativeArchiveFromEnvelope(envelope: Record<string, unknown> | null) {
45
85
  const sessionState =
46
86
  envelope?.sessionState && typeof envelope.sessionState === "object"
@@ -82,6 +122,7 @@ export async function establishApiSandboxSpawner(input: {
82
122
  acquiredLease: LeaseSnapshot;
83
123
  fallbackEnvelope: Record<string, unknown> | null;
84
124
  dataPlaneUrl: string | null;
125
+ objectStorage?: ObjectStorage | null;
85
126
  }): Promise<{ established: EstablishedSandboxSession; lease: LeaseSnapshot }> {
86
127
  const fallbackArchiveEnvelope =
87
128
  input.acquiredLease.recovery.archive.status === "none" &&
@@ -162,8 +203,10 @@ export async function establishApiSandboxSpawner(input: {
162
203
  );
163
204
  }
164
205
 
206
+ const hydrateEnvelope = await materializeArchiveObjectRef(spawnEnvelope, input.objectStorage);
207
+
165
208
  const providerCreateStartedAt = new Date();
166
- established = await establishSandboxSessionFromEnvelope(input.settings, spawnEnvelope, {
209
+ established = await establishSandboxSessionFromEnvelope(input.settings, hydrateEnvelope, {
167
210
  sessionId: input.sessionId,
168
211
  recovery: "create-or-restore",
169
212
  backendOverride: input.backend as never,
@@ -55,6 +55,7 @@ import {
55
55
  } from "@opengeni/db";
56
56
  import { appendAndPublishEvents, type EventBus } from "@opengeni/events";
57
57
  import { HTTPException } from "hono/http-exception";
58
+ import type { ObjectStorage } from "@opengeni/storage";
58
59
 
59
60
  // The leaf — agent-loop-free. apps/api imports sandbox symbols ONLY from here
60
61
  // (enforced by sandbox-access-import-guard.test.ts).
@@ -98,6 +99,7 @@ export type ViewerServices = {
98
99
  db: Database;
99
100
  settings: Settings;
100
101
  bus?: EventBus;
102
+ objectStorage?: ObjectStorage | null;
101
103
  /** Provider-establish dependency used by API-direct readiness and stream
102
104
  * operations. Production uses the runtime leaf; isolated tests may supply a
103
105
  * deterministic provider without replacing a process-global module. */
@@ -381,6 +383,7 @@ export async function attachViewer(
381
383
  acquiredLease: acquired.lease,
382
384
  fallbackEnvelope: envelope,
383
385
  dataPlaneUrl: null,
386
+ ...(services.objectStorage !== undefined ? { objectStorage: services.objectStorage } : {}),
384
387
  });
385
388
  established = result.established;
386
389
  return {