@opengeni/api-router 0.12.7 → 0.14.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts +18 -38
- package/dist/app.js +3 -1
- package/dist/auth/managed-auth.d.ts +35 -0
- package/dist/{chunk-SD5GJTZ4.js → chunk-36PW33ND.js} +3744 -638
- package/dist/chunk-36PW33ND.js.map +1 -0
- package/dist/codex-redemption-security.d.ts +15 -0
- package/dist/github-access.d.ts +12 -0
- package/dist/github-browser-flow.d.ts +14 -0
- package/dist/http/auth.d.ts +3 -0
- package/dist/http/common.d.ts +3 -0
- package/dist/http/sse.d.ts +70 -0
- package/dist/index.d.ts +11 -13
- package/dist/index.js +1 -1
- package/dist/integrations/google-drive.d.ts +76 -0
- package/dist/integrations/oauth-client.d.ts +42 -0
- package/dist/integrations/provider-domain.d.ts +7 -0
- package/dist/integrations/slack-bot.d.ts +343 -0
- package/dist/mcp/documents.d.ts +8 -0
- package/dist/mcp/files.d.ts +5 -0
- package/dist/mcp/server.d.ts +107 -0
- package/dist/mcp/session-view.d.ts +191 -0
- package/dist/mcp/toolspace.d.ts +61 -0
- package/dist/model-catalog.d.ts +39 -0
- package/dist/observability.d.ts +3 -0
- package/dist/routes/api-keys.d.ts +3 -0
- package/dist/routes/billing.d.ts +16 -0
- package/dist/routes/capabilities.d.ts +3 -0
- package/dist/routes/catalog-assets.d.ts +5 -0
- package/dist/routes/codex.d.ts +10 -0
- package/dist/routes/connections.d.ts +3 -0
- package/dist/routes/documents.d.ts +3 -0
- package/dist/routes/enrollments.d.ts +3 -0
- package/dist/routes/environments.d.ts +5 -0
- package/dist/routes/files.d.ts +4 -0
- package/dist/routes/github.d.ts +3 -0
- package/dist/routes/insights.d.ts +3 -0
- package/dist/routes/install.d.ts +5 -0
- package/dist/routes/machines.d.ts +3 -0
- package/dist/routes/packs.d.ts +3 -0
- package/dist/routes/preference-registry.d.ts +5 -0
- package/dist/routes/rigs.d.ts +3 -0
- package/dist/routes/scheduled-tasks.d.ts +3 -0
- package/dist/routes/sessions.d.ts +15 -0
- package/dist/routes/social.d.ts +3 -0
- package/dist/routes/transcriptions.d.ts +3 -0
- package/dist/routes/workspace-capture.d.ts +59 -0
- package/dist/routes/workspace-instruction-policies.d.ts +3 -0
- package/dist/routes/workspace-state.d.ts +3 -0
- package/dist/routes/workspaces.d.ts +5 -0
- package/dist/sandbox/access.d.ts +21 -0
- package/dist/sandbox/auth-callout.d.ts +30 -0
- package/dist/sandbox/channel-a.d.ts +37 -0
- package/dist/sandbox/enrollment.d.ts +120 -0
- package/dist/sandbox/machines.d.ts +29 -0
- package/dist/sandbox/metrics-ingestion.d.ts +128 -0
- package/dist/sandbox/rematerialize.d.ts +24 -0
- package/dist/sandbox/viewer.d.ts +251 -0
- package/dist/transcription/providers/azure-openai.d.ts +9 -0
- package/dist/transcription/providers/codex-subscription.d.ts +9 -0
- package/dist/transcription/providers/openai.d.ts +9 -0
- package/dist/transcription/service.d.ts +10 -0
- package/dist/workspace-state-projection.d.ts +16 -0
- package/package.json +13 -13
- package/src/app.ts +75 -5
- package/src/github-access.ts +1 -0
- package/src/integrations/google-drive.ts +869 -0
- package/src/integrations/oauth-client.ts +41 -6
- package/src/integrations/slack-bot.ts +756 -26
- package/src/mcp/server.ts +226 -42
- package/src/mcp/session-view.ts +1 -0
- package/src/mcp/toolspace.ts +17 -9
- package/src/routes/connections.ts +137 -7
- package/src/routes/documents.ts +13 -2
- package/src/routes/github.ts +327 -20
- package/src/routes/insights.ts +30 -0
- package/src/routes/preference-registry.ts +482 -0
- package/src/routes/sessions.ts +22 -23
- package/src/routes/transcriptions.ts +155 -0
- package/src/routes/workspace-capture.ts +108 -8
- package/src/routes/workspace-state.ts +61 -0
- package/src/sandbox/channel-a.ts +2 -0
- package/src/sandbox/rematerialize.ts +111 -4
- package/src/sandbox/viewer.ts +29 -12
- package/src/transcription/providers/azure-openai.ts +45 -0
- package/src/transcription/providers/codex-subscription.ts +100 -0
- package/src/transcription/providers/openai.ts +93 -0
- package/src/transcription/service.ts +121 -0
- package/src/workspace-state-projection.ts +244 -0
- package/dist/chunk-SD5GJTZ4.js.map +0 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveWorkspaceVoiceInputEnabled,
|
|
3
|
+
type TranscribeAudioResponse,
|
|
4
|
+
} from "@opengeni/contracts";
|
|
5
|
+
import { type ApiRouteDeps, requireAccessGrant, TranscriptionServiceError } from "@opengeni/core";
|
|
6
|
+
import { getWorkspace } from "@opengeni/db";
|
|
7
|
+
import type { Hono } from "hono";
|
|
8
|
+
|
|
9
|
+
export function registerTranscriptionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
10
|
+
app.post("/v1/workspaces/:workspaceId/transcriptions", async (c) => {
|
|
11
|
+
const workspaceId = c.req.param("workspaceId");
|
|
12
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:create");
|
|
13
|
+
const workspace = await getWorkspace(deps.db, workspaceId);
|
|
14
|
+
if (!workspace) return c.json({ code: "not_found" }, 404);
|
|
15
|
+
if (resolveWorkspaceVoiceInputEnabled(workspace.settings) === false) {
|
|
16
|
+
return c.json({ code: "policy_blocked" }, 403);
|
|
17
|
+
}
|
|
18
|
+
const service = deps.transcription;
|
|
19
|
+
if (!service || !(await service.available())) {
|
|
20
|
+
return c.json({ code: "unavailable" }, 503);
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const body = await audioRequest(c.req.raw, service.limits().maxSizeBytes);
|
|
24
|
+
const result = await service.transcribe({
|
|
25
|
+
workspaceId,
|
|
26
|
+
accountId: grant.accountId,
|
|
27
|
+
audio: body.audio,
|
|
28
|
+
mimeType: body.mimeType,
|
|
29
|
+
durationSeconds: body.durationSeconds,
|
|
30
|
+
signal: c.req.raw.signal,
|
|
31
|
+
requestId: c.req.header("x-opengeni-correlation-id") ?? crypto.randomUUID(),
|
|
32
|
+
});
|
|
33
|
+
const response: TranscribeAudioResponse = {
|
|
34
|
+
text: result.text,
|
|
35
|
+
languages: result.languages,
|
|
36
|
+
};
|
|
37
|
+
return c.json(response);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error instanceof TranscriptionServiceError) {
|
|
40
|
+
return c.json({ code: error.code }, error.status as never);
|
|
41
|
+
}
|
|
42
|
+
if (isAbort(error)) return c.json({ code: "cancelled" }, 499 as never);
|
|
43
|
+
return c.json({ code: "unknown" }, 500);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function audioRequest(
|
|
49
|
+
request: Request,
|
|
50
|
+
maxSizeBytes: number,
|
|
51
|
+
): Promise<{ audio: Uint8Array; mimeType: string; durationSeconds?: number }> {
|
|
52
|
+
const contentLength = Number(request.headers.get("content-length"));
|
|
53
|
+
if (Number.isFinite(contentLength) && contentLength > maxSizeBytes + 64 * 1024) {
|
|
54
|
+
throw new TranscriptionServiceError({
|
|
55
|
+
code: "too_large",
|
|
56
|
+
message: "Audio is too large.",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
60
|
+
if (contentType.toLowerCase().startsWith("multipart/form-data")) {
|
|
61
|
+
const form = await request.formData();
|
|
62
|
+
const audio = form.get("audio");
|
|
63
|
+
if (!(audio instanceof File)) {
|
|
64
|
+
throw new TranscriptionServiceError({
|
|
65
|
+
code: "invalid_audio",
|
|
66
|
+
message: "Audio file is required.",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (audio.size > maxSizeBytes) {
|
|
70
|
+
throw new TranscriptionServiceError({
|
|
71
|
+
code: "too_large",
|
|
72
|
+
message: "Audio is too large.",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const durationSeconds = durationValue(stringValue(form.get("durationSeconds")));
|
|
76
|
+
return {
|
|
77
|
+
audio: new Uint8Array(await audio.arrayBuffer()),
|
|
78
|
+
mimeType: stringValue(form.get("mimeType")) ?? audio.type,
|
|
79
|
+
...(durationSeconds === undefined ? {} : { durationSeconds }),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const mimeType = contentType.split(";", 1)[0]?.trim() ?? "";
|
|
83
|
+
if (!mimeType.toLowerCase().startsWith("audio/")) {
|
|
84
|
+
throw new TranscriptionServiceError({
|
|
85
|
+
code: "not_supported",
|
|
86
|
+
message: "Unsupported audio format.",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const durationSeconds = durationValue(request.headers.get("x-opengeni-audio-duration-seconds"));
|
|
90
|
+
return {
|
|
91
|
+
audio: await readBounded(request.body, maxSizeBytes, request.signal),
|
|
92
|
+
mimeType,
|
|
93
|
+
...(durationSeconds === undefined ? {} : { durationSeconds }),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function readBounded(
|
|
98
|
+
body: ReadableStream<Uint8Array> | null,
|
|
99
|
+
maxSizeBytes: number,
|
|
100
|
+
signal: AbortSignal,
|
|
101
|
+
): Promise<Uint8Array> {
|
|
102
|
+
if (!body)
|
|
103
|
+
throw new TranscriptionServiceError({
|
|
104
|
+
code: "invalid_audio",
|
|
105
|
+
message: "Audio is required.",
|
|
106
|
+
});
|
|
107
|
+
const reader = body.getReader();
|
|
108
|
+
const chunks: Uint8Array[] = [];
|
|
109
|
+
let size = 0;
|
|
110
|
+
try {
|
|
111
|
+
while (true) {
|
|
112
|
+
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
|
113
|
+
const next = await reader.read();
|
|
114
|
+
if (next.done) break;
|
|
115
|
+
size += next.value.byteLength;
|
|
116
|
+
if (size > maxSizeBytes) {
|
|
117
|
+
await reader.cancel();
|
|
118
|
+
throw new TranscriptionServiceError({
|
|
119
|
+
code: "too_large",
|
|
120
|
+
message: "Audio is too large.",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
chunks.push(next.value);
|
|
124
|
+
}
|
|
125
|
+
} finally {
|
|
126
|
+
reader.releaseLock();
|
|
127
|
+
}
|
|
128
|
+
const audio = new Uint8Array(size);
|
|
129
|
+
let offset = 0;
|
|
130
|
+
for (const chunk of chunks) {
|
|
131
|
+
audio.set(chunk, offset);
|
|
132
|
+
offset += chunk.byteLength;
|
|
133
|
+
}
|
|
134
|
+
return audio;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function durationValue(value: string | null | undefined): number | undefined {
|
|
138
|
+
if (value == null || value.trim() === "") return undefined;
|
|
139
|
+
const duration = Number(value);
|
|
140
|
+
if (!Number.isFinite(duration)) {
|
|
141
|
+
throw new TranscriptionServiceError({
|
|
142
|
+
code: "invalid_audio",
|
|
143
|
+
message: "Invalid audio duration.",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return duration;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function stringValue(value: FormDataEntryValue | null): string | undefined {
|
|
150
|
+
return typeof value === "string" ? value : undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isAbort(error: unknown): boolean {
|
|
154
|
+
return error instanceof DOMException && error.name === "AbortError";
|
|
155
|
+
}
|
|
@@ -38,6 +38,97 @@ export type CaptureStoragePort = {
|
|
|
38
38
|
}) => Promise<{ url: string; expiresAt: Date }>;
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
+
type LoadedManifest = {
|
|
42
|
+
manifest: WorkspaceCaptureManifest;
|
|
43
|
+
byteLength: number;
|
|
44
|
+
stats: WorkspaceCaptureStats;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type ManifestCacheEntry = LoadedManifest & {
|
|
48
|
+
identity: string;
|
|
49
|
+
inlineResponse?: Extract<GetWorkspaceCaptureResponse, { available: true }>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Process-local cache for immutable capture manifests. Capture rows point at a
|
|
54
|
+
* revision-specific object key, so a successfully validated blob is safe to
|
|
55
|
+
* reuse as long as the row identity still matches. Both entry count and bytes
|
|
56
|
+
* are bounded; large signed-manifest responses bypass the cache.
|
|
57
|
+
*/
|
|
58
|
+
export class WorkspaceCaptureManifestCache {
|
|
59
|
+
readonly #entries = new Map<string, ManifestCacheEntry>();
|
|
60
|
+
#retainedBytes = 0;
|
|
61
|
+
|
|
62
|
+
constructor(
|
|
63
|
+
readonly maxEntries = 64,
|
|
64
|
+
readonly maxBytes = 16 * 1024 * 1024,
|
|
65
|
+
) {}
|
|
66
|
+
|
|
67
|
+
get(row: WorkspaceCaptureRow): LoadedManifest | null {
|
|
68
|
+
const key = row.manifestKey;
|
|
69
|
+
if (!key) return null;
|
|
70
|
+
const entry = this.#entries.get(key);
|
|
71
|
+
if (!entry || entry.identity !== manifestIdentity(row)) return null;
|
|
72
|
+
this.#entries.delete(key);
|
|
73
|
+
this.#entries.set(key, entry);
|
|
74
|
+
return entry;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
getInlineResponse(
|
|
78
|
+
row: WorkspaceCaptureRow,
|
|
79
|
+
): Extract<GetWorkspaceCaptureResponse, { available: true }> | null {
|
|
80
|
+
const key = row.manifestKey;
|
|
81
|
+
if (!key) return null;
|
|
82
|
+
const entry = this.#entries.get(key);
|
|
83
|
+
if (!entry || entry.identity !== manifestIdentity(row) || !entry.inlineResponse) return null;
|
|
84
|
+
this.#entries.delete(key);
|
|
85
|
+
this.#entries.set(key, entry);
|
|
86
|
+
return entry.inlineResponse;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
setInlineResponse(
|
|
90
|
+
row: WorkspaceCaptureRow,
|
|
91
|
+
response: Extract<GetWorkspaceCaptureResponse, { available: true }>,
|
|
92
|
+
): void {
|
|
93
|
+
const key = row.manifestKey;
|
|
94
|
+
if (!key) return;
|
|
95
|
+
const entry = this.#entries.get(key);
|
|
96
|
+
if (!entry || entry.identity !== manifestIdentity(row)) return;
|
|
97
|
+
entry.inlineResponse = response;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
set(row: WorkspaceCaptureRow, loaded: LoadedManifest): void {
|
|
101
|
+
const key = row.manifestKey;
|
|
102
|
+
if (
|
|
103
|
+
!key ||
|
|
104
|
+
this.maxEntries <= 0 ||
|
|
105
|
+
this.maxBytes <= 0 ||
|
|
106
|
+
loaded.byteLength > CAPTURE_INLINE_MANIFEST_MAX_BYTES ||
|
|
107
|
+
loaded.byteLength > this.maxBytes
|
|
108
|
+
) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const previous = this.#entries.get(key);
|
|
112
|
+
if (previous) {
|
|
113
|
+
this.#retainedBytes -= previous.byteLength;
|
|
114
|
+
this.#entries.delete(key);
|
|
115
|
+
}
|
|
116
|
+
this.#entries.set(key, { ...loaded, identity: manifestIdentity(row) });
|
|
117
|
+
this.#retainedBytes += loaded.byteLength;
|
|
118
|
+
while (this.#entries.size > this.maxEntries || this.#retainedBytes > this.maxBytes) {
|
|
119
|
+
const oldestKey = this.#entries.keys().next().value;
|
|
120
|
+
if (oldestKey === undefined) break;
|
|
121
|
+
const oldest = this.#entries.get(oldestKey);
|
|
122
|
+
this.#entries.delete(oldestKey);
|
|
123
|
+
if (oldest) this.#retainedBytes -= oldest.byteLength;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function manifestIdentity(row: WorkspaceCaptureRow): string {
|
|
129
|
+
return `${row.sessionId}\0${row.turnId}\0${row.revision}\0${row.leaseEpoch}\0${row.capturedAt}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
41
132
|
function signedUrl(signed: { url: string; expiresAt: Date }): { url: string; expiresAt: string } {
|
|
42
133
|
return { url: signed.url, expiresAt: signed.expiresAt.toISOString() };
|
|
43
134
|
}
|
|
@@ -51,12 +142,11 @@ function signedUrl(signed: { url: string; expiresAt: Date }): { url: string; exp
|
|
|
51
142
|
async function loadManifest(
|
|
52
143
|
row: WorkspaceCaptureRow,
|
|
53
144
|
storage: CaptureStoragePort,
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
byteLength: number;
|
|
57
|
-
stats: WorkspaceCaptureStats;
|
|
58
|
-
} | null> {
|
|
145
|
+
cache?: WorkspaceCaptureManifestCache,
|
|
146
|
+
): Promise<LoadedManifest | null> {
|
|
59
147
|
if (!row.manifestKey) return null;
|
|
148
|
+
const cached = cache?.get(row);
|
|
149
|
+
if (cached) return cached;
|
|
60
150
|
const stats = WorkspaceCaptureStats.safeParse(row.stats);
|
|
61
151
|
if (!stats.success) {
|
|
62
152
|
console.warn(
|
|
@@ -114,7 +204,9 @@ async function loadManifest(
|
|
|
114
204
|
);
|
|
115
205
|
return null;
|
|
116
206
|
}
|
|
117
|
-
|
|
207
|
+
const loaded = { manifest, byteLength: blob.bytes.byteLength, stats: servedStats };
|
|
208
|
+
cache?.set(row, loaded);
|
|
209
|
+
return loaded;
|
|
118
210
|
}
|
|
119
211
|
|
|
120
212
|
/**
|
|
@@ -126,6 +218,7 @@ async function loadManifest(
|
|
|
126
218
|
export async function serveWorkspaceCapture(
|
|
127
219
|
row: WorkspaceCaptureRow | null,
|
|
128
220
|
storage: CaptureStoragePort,
|
|
221
|
+
cache?: WorkspaceCaptureManifestCache,
|
|
129
222
|
): Promise<GetWorkspaceCaptureResponse> {
|
|
130
223
|
if (!row) {
|
|
131
224
|
return { available: false };
|
|
@@ -148,11 +241,13 @@ export async function serveWorkspaceCapture(
|
|
|
148
241
|
});
|
|
149
242
|
}
|
|
150
243
|
if (row.state !== "available" || !row.manifestKey) return { available: false };
|
|
244
|
+
const cachedResponse = cache?.getInlineResponse(row);
|
|
245
|
+
if (cachedResponse) return cachedResponse;
|
|
151
246
|
// Validate every manifest before serving it, including the rare >2MB signed
|
|
152
247
|
// path. Previously that branch signed arbitrary bytes merely because they
|
|
153
248
|
// exceeded the inline cap, allowing a poison/mis-keyed blob to bypass both the
|
|
154
249
|
// schema and row-identity checks.
|
|
155
|
-
const loaded = await loadManifest(row, storage);
|
|
250
|
+
const loaded = await loadManifest(row, storage, cache);
|
|
156
251
|
if (!loaded) return { available: false };
|
|
157
252
|
const meta = {
|
|
158
253
|
available: true as const,
|
|
@@ -164,11 +259,16 @@ export async function serveWorkspaceCapture(
|
|
|
164
259
|
stats: loaded.stats,
|
|
165
260
|
};
|
|
166
261
|
if (loaded.byteLength <= CAPTURE_INLINE_MANIFEST_MAX_BYTES) {
|
|
167
|
-
|
|
262
|
+
const response = GetWorkspaceCaptureResponse.parse({
|
|
168
263
|
...meta,
|
|
169
264
|
manifest: loaded.manifest,
|
|
170
265
|
manifestUrl: null,
|
|
171
266
|
});
|
|
267
|
+
if (!response.available) {
|
|
268
|
+
throw new Error("validated inline capture response lost its available discriminator");
|
|
269
|
+
}
|
|
270
|
+
cache?.setInlineResponse(row, response);
|
|
271
|
+
return response;
|
|
172
272
|
}
|
|
173
273
|
const signed = await storage.createGetUrl({
|
|
174
274
|
key: row.manifestKey,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {
|
|
2
|
+
WORKSPACE_STATE_MAX_BASES,
|
|
3
|
+
WORKSPACE_STATE_MAX_TOPICS,
|
|
4
|
+
WORKSPACE_STATE_TOPIC_MAX_CHARS,
|
|
5
|
+
WorkspaceStateResponse,
|
|
6
|
+
} from "@opengeni/contracts";
|
|
7
|
+
import { hasPermission, requireAccessGrant, type ApiRouteDeps } from "@opengeni/core";
|
|
8
|
+
import {
|
|
9
|
+
getWorkspace,
|
|
10
|
+
listWorkspaceStateMemoryRecords,
|
|
11
|
+
listWorkspaceInstructionPolicyRevisions,
|
|
12
|
+
} from "@opengeni/db";
|
|
13
|
+
import { getDocumentInventory } from "@opengeni/documents";
|
|
14
|
+
import type { Hono } from "hono";
|
|
15
|
+
import { HTTPException } from "hono/http-exception";
|
|
16
|
+
|
|
17
|
+
import { projectWorkspaceState } from "../workspace-state-projection";
|
|
18
|
+
|
|
19
|
+
export function registerWorkspaceStateRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
20
|
+
app.get("/v1/workspaces/:workspaceId/workspace-state", async (context) => {
|
|
21
|
+
const workspaceId = context.req.param("workspaceId");
|
|
22
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:read");
|
|
23
|
+
const generatedAt = new Date().toISOString();
|
|
24
|
+
const canInspectKnowledge = hasPermission(grant.permissions, "documents:search");
|
|
25
|
+
|
|
26
|
+
const [workspace, policies, knowledge] = await Promise.all([
|
|
27
|
+
getWorkspace(deps.db, workspaceId),
|
|
28
|
+
listWorkspaceInstructionPolicyRevisions(deps.db, workspaceId, { limit: 1 }),
|
|
29
|
+
canInspectKnowledge
|
|
30
|
+
? (async () => {
|
|
31
|
+
const [documents, memories] = await Promise.all([
|
|
32
|
+
getDocumentInventory(deps.db, workspaceId, {
|
|
33
|
+
baseLimit: WORKSPACE_STATE_MAX_BASES,
|
|
34
|
+
topicLimit: WORKSPACE_STATE_MAX_TOPICS,
|
|
35
|
+
topicMaxChars: WORKSPACE_STATE_TOPIC_MAX_CHARS,
|
|
36
|
+
access: { viewerSubjectId: grant.subjectId },
|
|
37
|
+
}),
|
|
38
|
+
listWorkspaceStateMemoryRecords(deps.db, workspaceId),
|
|
39
|
+
]);
|
|
40
|
+
return { documents, memories };
|
|
41
|
+
})()
|
|
42
|
+
: Promise.resolve(null),
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
if (!workspace) {
|
|
46
|
+
throw new HTTPException(404, { message: "workspace not found" });
|
|
47
|
+
}
|
|
48
|
+
context.header("cache-control", "private, no-store");
|
|
49
|
+
return context.json(
|
|
50
|
+
WorkspaceStateResponse.parse(
|
|
51
|
+
projectWorkspaceState({
|
|
52
|
+
workspaceId,
|
|
53
|
+
generatedAt,
|
|
54
|
+
workspaceAgentInstructions: workspace.agentInstructions,
|
|
55
|
+
policies,
|
|
56
|
+
knowledge,
|
|
57
|
+
}),
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
}
|
package/src/sandbox/channel-a.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
applyGitAuthPointerEnvironment,
|
|
22
22
|
hasGitCredentialRepositorySelection,
|
|
23
23
|
hasGitHubRepositorySelection,
|
|
24
|
+
sandboxArchiveCaptureTimeoutMs,
|
|
24
25
|
stableSandboxEnvironmentForRun,
|
|
25
26
|
type Settings,
|
|
26
27
|
} from "@opengeni/config";
|
|
@@ -247,6 +248,7 @@ export async function withChannelA<T>(
|
|
|
247
248
|
os: session.sandboxOs,
|
|
248
249
|
leaseTtlMs,
|
|
249
250
|
warmingLeaseTtlMs: settings.sandboxWarmingTimeoutMs,
|
|
251
|
+
captureWaitMs: sandboxArchiveCaptureTimeoutMs(settings),
|
|
250
252
|
});
|
|
251
253
|
|
|
252
254
|
if (acquired.role === "blocked") {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Settings } from "@opengeni/config";
|
|
2
2
|
import {
|
|
3
|
+
adoptLegacyModalCheckpointArtifact,
|
|
3
4
|
beginSandboxRematerialization,
|
|
4
5
|
commitWarmingToWarm,
|
|
5
6
|
failSandboxRematerialization,
|
|
@@ -12,9 +13,13 @@ import {
|
|
|
12
13
|
type LeaseSnapshot,
|
|
13
14
|
} from "@opengeni/db";
|
|
14
15
|
import {
|
|
16
|
+
describeLegacyNativeSnapshotArchive,
|
|
15
17
|
establishSandboxSessionFromEnvelope,
|
|
16
18
|
isProviderSandboxNotFoundError,
|
|
19
|
+
modalSessionMatchesCheckpointProviderBinding,
|
|
20
|
+
parseWorkspaceArchiveDescriptor,
|
|
17
21
|
requirePersistableReplacementSandboxEnvelope,
|
|
22
|
+
resolveModalCheckpointProviderBindingForSession,
|
|
18
23
|
serializeReplacementSandboxEnvelope,
|
|
19
24
|
tagModalSandbox,
|
|
20
25
|
verifySandboxExecReadiness,
|
|
@@ -33,6 +38,20 @@ function hasWorkspaceArchive(envelope: Record<string, unknown> | null): boolean
|
|
|
33
38
|
);
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
function legacyNativeArchiveFromEnvelope(envelope: Record<string, unknown> | null) {
|
|
42
|
+
const sessionState =
|
|
43
|
+
envelope?.sessionState && typeof envelope.sessionState === "object"
|
|
44
|
+
? (envelope.sessionState as Record<string, unknown>)
|
|
45
|
+
: null;
|
|
46
|
+
if (!sessionState) return null;
|
|
47
|
+
const existing = parseWorkspaceArchiveDescriptor(sessionState.workspaceArchiveMeta);
|
|
48
|
+
if (existing?.version === 2) return null;
|
|
49
|
+
return describeLegacyNativeSnapshotArchive(
|
|
50
|
+
sessionState.workspaceArchive,
|
|
51
|
+
existing?.version === 1 ? Date.parse(existing.capturedAt) : Date.now(),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
36
55
|
function withoutProviderIdentity(
|
|
37
56
|
envelope: Record<string, unknown> | null,
|
|
38
57
|
): Record<string, unknown> | null {
|
|
@@ -48,7 +67,9 @@ function withoutProviderIdentity(
|
|
|
48
67
|
|
|
49
68
|
async function terminateCreated(established: EstablishedSandboxSession | null): Promise<boolean> {
|
|
50
69
|
if (!established) return true;
|
|
51
|
-
const client = established.client as {
|
|
70
|
+
const client = established.client as {
|
|
71
|
+
delete?: (state: unknown) => Promise<unknown>;
|
|
72
|
+
};
|
|
52
73
|
try {
|
|
53
74
|
if (typeof client.delete === "function" && established.sessionState !== undefined) {
|
|
54
75
|
await client.delete(established.sessionState);
|
|
@@ -93,20 +114,30 @@ export async function establishApiSandboxSpawner(input: {
|
|
|
93
114
|
hasWorkspaceArchive(input.fallbackEnvelope)
|
|
94
115
|
? withoutProviderIdentity(input.fallbackEnvelope)
|
|
95
116
|
: null;
|
|
96
|
-
|
|
117
|
+
let spawnEnvelope =
|
|
97
118
|
fallbackArchiveEnvelope ?? input.acquiredLease.resumeState ?? input.fallbackEnvelope;
|
|
98
119
|
const archiveSource =
|
|
99
120
|
input.acquiredLease.recovery.archive.status === "none"
|
|
100
121
|
? fallbackArchiveEnvelope
|
|
101
122
|
: input.acquiredLease.resumeState;
|
|
102
123
|
let established: EstablishedSandboxSession | null = null;
|
|
103
|
-
let rematerialization: {
|
|
124
|
+
let rematerialization: {
|
|
125
|
+
id: string;
|
|
126
|
+
selectedRevision: string;
|
|
127
|
+
workspaceGeneration: number;
|
|
128
|
+
providerBindingKey: string | null;
|
|
129
|
+
legacyCheckpoint: ReturnType<typeof legacyNativeArchiveFromEnvelope>;
|
|
130
|
+
legacyProviderBinding: Awaited<
|
|
131
|
+
ReturnType<typeof resolveModalCheckpointProviderBindingForSession>
|
|
132
|
+
> | null;
|
|
133
|
+
} | null = null;
|
|
104
134
|
try {
|
|
105
135
|
if (
|
|
106
136
|
input.acquiredLease.recovery.archive.status === "available" ||
|
|
107
137
|
hasWorkspaceArchive(archiveSource)
|
|
108
138
|
) {
|
|
109
139
|
const id = crypto.randomUUID();
|
|
140
|
+
const legacyNativeArchive = legacyNativeArchiveFromEnvelope(archiveSource);
|
|
110
141
|
const begun = await beginSandboxRematerialization(input.db, {
|
|
111
142
|
accountId: input.accountId,
|
|
112
143
|
workspaceId: input.workspaceId,
|
|
@@ -114,6 +145,7 @@ export async function establishApiSandboxSpawner(input: {
|
|
|
114
145
|
expectedEpoch: input.expectedEpoch,
|
|
115
146
|
rematerializationId: id,
|
|
116
147
|
archiveSource,
|
|
148
|
+
legacyNativeArchive,
|
|
117
149
|
});
|
|
118
150
|
if (begun.status !== "started") {
|
|
119
151
|
if (begun.code === "stale_epoch" || begun.code === "attempt_conflict") {
|
|
@@ -129,6 +161,7 @@ export async function establishApiSandboxSpawner(input: {
|
|
|
129
161
|
begun.lease?.recovery ?? input.acquiredLease.recovery,
|
|
130
162
|
);
|
|
131
163
|
}
|
|
164
|
+
spawnEnvelope = begun.lease.resumeState ?? spawnEnvelope;
|
|
132
165
|
const selectedRevision = begun.lease.recovery.restore.selectedRevision;
|
|
133
166
|
if (!selectedRevision) {
|
|
134
167
|
throw new WorkspaceArchiveIntegrityError(
|
|
@@ -136,7 +169,14 @@ export async function establishApiSandboxSpawner(input: {
|
|
|
136
169
|
"sandbox rematerialization selected no durable archive revision",
|
|
137
170
|
);
|
|
138
171
|
}
|
|
139
|
-
rematerialization = {
|
|
172
|
+
rematerialization = {
|
|
173
|
+
id,
|
|
174
|
+
selectedRevision,
|
|
175
|
+
workspaceGeneration: begun.lease.workspaceGeneration,
|
|
176
|
+
providerBindingKey: begun.checkpointArtifact?.providerBindingKey ?? null,
|
|
177
|
+
legacyCheckpoint: begun.checkpointArtifact === null ? legacyNativeArchive : null,
|
|
178
|
+
legacyProviderBinding: null,
|
|
179
|
+
};
|
|
140
180
|
} else if (input.acquiredLease.recovery.archive.status !== "none") {
|
|
141
181
|
throw new SandboxLeaseRecoveryBlockedError(
|
|
142
182
|
input.sandboxGroupId,
|
|
@@ -146,6 +186,7 @@ export async function establishApiSandboxSpawner(input: {
|
|
|
146
186
|
);
|
|
147
187
|
}
|
|
148
188
|
|
|
189
|
+
const providerCreateStartedAt = new Date();
|
|
149
190
|
established = await establishSandboxSessionFromEnvelope(input.settings, spawnEnvelope, {
|
|
150
191
|
sessionId: input.sessionId,
|
|
151
192
|
recovery: "create-or-restore",
|
|
@@ -153,6 +194,37 @@ export async function establishApiSandboxSpawner(input: {
|
|
|
153
194
|
environment: input.environment,
|
|
154
195
|
onSandboxCreated: async (created) => {
|
|
155
196
|
established = created;
|
|
197
|
+
if (
|
|
198
|
+
rematerialization &&
|
|
199
|
+
(rematerialization.providerBindingKey || rematerialization.legacyCheckpoint)
|
|
200
|
+
) {
|
|
201
|
+
if (created.backendId !== "modal") {
|
|
202
|
+
throw new WorkspaceArchiveIntegrityError(
|
|
203
|
+
"native_snapshot_reference_invalid",
|
|
204
|
+
"Modal checkpoint restore resolved a non-Modal sandbox backend",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (rematerialization.providerBindingKey) {
|
|
208
|
+
if (
|
|
209
|
+
!(await modalSessionMatchesCheckpointProviderBinding(
|
|
210
|
+
input.settings,
|
|
211
|
+
created.session,
|
|
212
|
+
rematerialization.providerBindingKey,
|
|
213
|
+
))
|
|
214
|
+
) {
|
|
215
|
+
throw new WorkspaceArchiveIntegrityError(
|
|
216
|
+
"native_snapshot_reference_invalid",
|
|
217
|
+
"Modal checkpoint restore refused because the authenticated provider workspace changed",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
rematerialization.legacyProviderBinding =
|
|
222
|
+
await resolveModalCheckpointProviderBindingForSession(
|
|
223
|
+
input.settings,
|
|
224
|
+
created.session,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
156
228
|
const resumeState = requirePersistableReplacementSandboxEnvelope(
|
|
157
229
|
await serializeReplacementSandboxEnvelope(created, spawnEnvelope),
|
|
158
230
|
created.backendId,
|
|
@@ -166,6 +238,14 @@ export async function establishApiSandboxSpawner(input: {
|
|
|
166
238
|
instanceId: created.instanceId,
|
|
167
239
|
resumeBackendId: created.backendId,
|
|
168
240
|
resumeState,
|
|
241
|
+
...(created.backendId === "modal"
|
|
242
|
+
? {
|
|
243
|
+
providerCreatedAt: providerCreateStartedAt,
|
|
244
|
+
providerDeadlineAt: new Date(
|
|
245
|
+
providerCreateStartedAt.getTime() + input.settings.modalTimeoutSeconds * 1000,
|
|
246
|
+
),
|
|
247
|
+
}
|
|
248
|
+
: {}),
|
|
169
249
|
leaseTtlMs: input.settings.sandboxLeaseTtlMs,
|
|
170
250
|
warmingLeaseTtlMs: input.settings.sandboxWarmingTimeoutMs,
|
|
171
251
|
});
|
|
@@ -187,6 +267,33 @@ export async function establishApiSandboxSpawner(input: {
|
|
|
187
267
|
`hydrated archive revision ${descriptor.revision} does not match the selected rematerialization revision`,
|
|
188
268
|
);
|
|
189
269
|
}
|
|
270
|
+
if (rematerialization.legacyCheckpoint) {
|
|
271
|
+
const binding = rematerialization.legacyProviderBinding;
|
|
272
|
+
if (!binding) {
|
|
273
|
+
throw new WorkspaceArchiveIntegrityError(
|
|
274
|
+
"native_snapshot_reference_invalid",
|
|
275
|
+
"Legacy Modal checkpoint restore produced no authenticated provider identity",
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
const adopted = await adoptLegacyModalCheckpointArtifact(input.db, {
|
|
279
|
+
accountId: input.accountId,
|
|
280
|
+
workspaceId: input.workspaceId,
|
|
281
|
+
sandboxGroupId: input.sandboxGroupId,
|
|
282
|
+
leaseId: input.acquiredLease.id,
|
|
283
|
+
leaseEpoch: input.expectedEpoch,
|
|
284
|
+
workspaceGeneration: rematerialization.workspaceGeneration,
|
|
285
|
+
slot: "current",
|
|
286
|
+
archiveBase64: rematerialization.legacyCheckpoint.archiveBase64,
|
|
287
|
+
descriptor: rematerialization.legacyCheckpoint.descriptor,
|
|
288
|
+
providerBindingKey: binding.key,
|
|
289
|
+
providerBinding: binding.binding,
|
|
290
|
+
rematerializationId: rematerialization.id,
|
|
291
|
+
});
|
|
292
|
+
if (!adopted) {
|
|
293
|
+
throw new SandboxLeaseSupersededError(input.sandboxGroupId, input.expectedEpoch);
|
|
294
|
+
}
|
|
295
|
+
rematerialization.providerBindingKey = binding.key;
|
|
296
|
+
}
|
|
190
297
|
const verifying = await markSandboxRestoreVerifying(input.db, {
|
|
191
298
|
accountId: input.accountId,
|
|
192
299
|
workspaceId: input.workspaceId,
|
package/src/sandbox/viewer.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
hasGitCredentialRepositorySelection,
|
|
25
25
|
hasGitHubRepositorySelection,
|
|
26
26
|
resolveStreamTokenSecret,
|
|
27
|
+
sandboxArchiveCaptureTimeoutMs,
|
|
27
28
|
stableSandboxEnvironmentForRun,
|
|
28
29
|
} from "@opengeni/config";
|
|
29
30
|
import type { Settings } from "@opengeni/config";
|
|
@@ -42,6 +43,7 @@ import {
|
|
|
42
43
|
recordLeaseTerminalDataPlaneUrl,
|
|
43
44
|
releaseLeaseHolder,
|
|
44
45
|
SandboxLeaseSupersededError,
|
|
46
|
+
SandboxViewerAdmissionBlockedError,
|
|
45
47
|
type Database,
|
|
46
48
|
type LeaseSnapshot,
|
|
47
49
|
type SandboxRecord,
|
|
@@ -195,18 +197,33 @@ export async function attachViewer(
|
|
|
195
197
|
});
|
|
196
198
|
};
|
|
197
199
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
200
|
+
let acquired: Awaited<ReturnType<typeof acquireLease>>;
|
|
201
|
+
try {
|
|
202
|
+
acquired = await acquireLease(db, {
|
|
203
|
+
accountId,
|
|
204
|
+
workspaceId,
|
|
205
|
+
sandboxGroupId,
|
|
206
|
+
kind: "viewer",
|
|
207
|
+
holderId: viewerId,
|
|
208
|
+
subjectId: session.id,
|
|
209
|
+
backend: session.sandboxBackend,
|
|
210
|
+
os: session.sandboxOs,
|
|
211
|
+
leaseTtlMs,
|
|
212
|
+
warmingLeaseTtlMs: settings.sandboxWarmingTimeoutMs,
|
|
213
|
+
captureWaitMs: sandboxArchiveCaptureTimeoutMs(settings),
|
|
214
|
+
});
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (error instanceof SandboxViewerAdmissionBlockedError) {
|
|
217
|
+
throw new HTTPException(error.reason === "balance" ? 402 : 429, {
|
|
218
|
+
message:
|
|
219
|
+
error.reason === "balance"
|
|
220
|
+
? "insufficient OpenGeni credits for an idle sandbox viewer"
|
|
221
|
+
: "workspace sandbox warm allowance exhausted",
|
|
222
|
+
cause: error,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
210
227
|
|
|
211
228
|
// FENCED: a newer epoch re-established the box. Release our just-registered
|
|
212
229
|
// holder and surface a 409 — the client re-reads capabilities and re-attaches.
|