@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.
- package/dist/app.js +1 -1
- package/dist/{chunk-XXH7DW34.js → chunk-BVX5ASFY.js} +1559 -798
- package/dist/chunk-BVX5ASFY.js.map +1 -0
- package/dist/codemode.d.ts +3 -0
- package/dist/index.js +1 -1
- package/dist/integrations/slack-interactions.d.ts +1 -1
- package/dist/mcp/command-read.d.ts +24 -0
- package/dist/mcp/scheduled-task-view.d.ts +3 -3
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/session-event-view.d.ts +86 -0
- package/dist/sandbox/channel-a.d.ts +2 -0
- package/dist/sandbox/rematerialize.d.ts +2 -0
- package/dist/sandbox/viewer.d.ts +2 -0
- package/dist/session-event-view-MU3HMNV3.js +264 -0
- package/dist/session-event-view-MU3HMNV3.js.map +1 -0
- package/dist/site-uploads.d.ts +41 -0
- package/dist/workspace-artifact-content.d.ts +4 -4
- package/package.json +19 -19
- package/src/app.ts +20 -0
- package/src/codemode.ts +52 -0
- package/src/integrations/oauth-client.ts +51 -7
- package/src/mcp/command-read.ts +109 -0
- package/src/mcp/server.ts +287 -153
- package/src/mcp/session-event-view.ts +336 -0
- package/src/mcp/session-wait.ts +1 -1
- package/src/routes/github.ts +124 -1
- package/src/routes/scheduled-tasks.ts +19 -1
- package/src/routes/sessions.ts +150 -7
- package/src/routes/workspace-artifacts.ts +92 -21
- package/src/sandbox/channel-a.ts +5 -0
- package/src/sandbox/rematerialize.ts +46 -3
- package/src/sandbox/viewer.ts +3 -0
- package/src/site-uploads.ts +202 -0
- package/src/workspace-artifact-content.ts +20 -39
- package/dist/chunk-XXH7DW34.js.map +0 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { WorkspaceArtifactSourceBundle, type AccessGrant } from "@opengeni/contracts";
|
|
2
|
+
import type { ApiRouteDeps, ObjectStorageDependency } from "@opengeni/core";
|
|
3
|
+
import {
|
|
4
|
+
createWorkspaceArtifactUpload,
|
|
5
|
+
getWorkspaceArtifactUpload,
|
|
6
|
+
expireWorkspaceArtifactUploads,
|
|
7
|
+
WorkspaceArtifactOperationError,
|
|
8
|
+
} from "@opengeni/db";
|
|
9
|
+
import {
|
|
10
|
+
prepareWorkspaceArtifactContent,
|
|
11
|
+
type PreparedWorkspaceArtifactContent,
|
|
12
|
+
} from "./workspace-artifact-content";
|
|
13
|
+
type Storage = NonNullable<ObjectStorageDependency>;
|
|
14
|
+
export const MAX_SITE_SOURCE_BYTES = 64 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
export async function validateSiteSource(storage: Storage, key: string, sizeBytes: number) {
|
|
17
|
+
if (sizeBytes > MAX_SITE_SOURCE_BYTES) {
|
|
18
|
+
throw new WorkspaceArtifactOperationError(
|
|
19
|
+
"Editable source JSON must be at most 64 MiB. Exclude dependencies and build output; HTML has a separate storage limit.",
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
const content = await storage.getObjectBytes(key);
|
|
23
|
+
try {
|
|
24
|
+
WorkspaceArtifactSourceBundle.parse(
|
|
25
|
+
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(content!.bytes)),
|
|
26
|
+
);
|
|
27
|
+
} catch {
|
|
28
|
+
throw new WorkspaceArtifactOperationError(
|
|
29
|
+
"Source must be JSON {entrypoint, files: [{path, content}]}. Correct it and prepare a new upload.",
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function keys(workspaceId: string, id: string) {
|
|
34
|
+
const base = `workspaces/${workspaceId}/workspace-artifacts`;
|
|
35
|
+
return {
|
|
36
|
+
html: `${base}/uploads/${id}/index.html`,
|
|
37
|
+
source: `${base}/uploads/${id}/source.json`,
|
|
38
|
+
frozenHtml: `${base}/blobs/${id}.html`,
|
|
39
|
+
frozenSource: `${base}/sources/${id}.json`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function owner(grant: AccessGrant) {
|
|
43
|
+
return typeof grant.metadata?.sessionId === "string"
|
|
44
|
+
? `session:${grant.metadata.sessionId}`
|
|
45
|
+
: `subject:${grant.subjectId}`;
|
|
46
|
+
}
|
|
47
|
+
export async function prepareWorkspaceArtifactUpload(deps: ApiRouteDeps, grant: AccessGrant) {
|
|
48
|
+
const storage = deps.objectStorage;
|
|
49
|
+
if (!storage) throw new Error("Object storage is not configured");
|
|
50
|
+
const expired = await expireWorkspaceArtifactUploads(deps.db, grant.workspaceId);
|
|
51
|
+
await Promise.allSettled(
|
|
52
|
+
expired.flatMap((row) => {
|
|
53
|
+
const paths = keys(row.workspaceId, row.id);
|
|
54
|
+
return (row.status === "published" ? [paths.html, paths.source] : Object.values(paths)).map(
|
|
55
|
+
(key) => storage.deleteObject(key),
|
|
56
|
+
);
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
const row = await createWorkspaceArtifactUpload(deps.db, {
|
|
60
|
+
accountId: grant.accountId,
|
|
61
|
+
workspaceId: grant.workspaceId,
|
|
62
|
+
ownerId: owner(grant),
|
|
63
|
+
});
|
|
64
|
+
const paths = keys(grant.workspaceId, row.id);
|
|
65
|
+
const audience = grant.principalKind === "agent_attempt" ? "sandbox" : "public";
|
|
66
|
+
const [html, source] = await Promise.all([
|
|
67
|
+
storage.createPutUrl({ key: paths.html, contentType: "text/html", audience }),
|
|
68
|
+
storage.createPutUrl({ key: paths.source, contentType: "application/json", audience }),
|
|
69
|
+
]);
|
|
70
|
+
return {
|
|
71
|
+
uploadId: row.id,
|
|
72
|
+
html: { putUrl: html.url, requiredHeaders: html.requiredHeaders },
|
|
73
|
+
source: { putUrl: source.url, requiredHeaders: source.requiredHeaders },
|
|
74
|
+
expiresAt: new Date(
|
|
75
|
+
Math.min(html.expiresAt.getTime(), source.expiresAt.getTime()),
|
|
76
|
+
).toISOString(),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** Provider version-pinned reads, without Site SHA checks or full HTML buffering. */
|
|
80
|
+
export async function* readArtifactObject(
|
|
81
|
+
storage: Storage,
|
|
82
|
+
key: string,
|
|
83
|
+
expectedHead?: Awaited<ReturnType<NonNullable<Storage["headObject"]>>>,
|
|
84
|
+
) {
|
|
85
|
+
if (!storage.headObject || !storage.getObjectRange)
|
|
86
|
+
throw new Error("Object storage cannot stream Site content");
|
|
87
|
+
const head = expectedHead ?? (await storage.headObject(key));
|
|
88
|
+
if (!head || !head.VersionToken || head.ContentLength === undefined)
|
|
89
|
+
throw new Error("Site content is unavailable");
|
|
90
|
+
for (let start = 0; start < head.ContentLength; start += 1024 * 1024) {
|
|
91
|
+
const endInclusive = Math.min(head.ContentLength - 1, start + 1024 * 1024 - 1);
|
|
92
|
+
const part = await storage.getObjectRange({
|
|
93
|
+
key,
|
|
94
|
+
start,
|
|
95
|
+
endInclusive,
|
|
96
|
+
expectedVersionToken: head.VersionToken,
|
|
97
|
+
});
|
|
98
|
+
if (
|
|
99
|
+
!part ||
|
|
100
|
+
part.versionToken !== head.VersionToken ||
|
|
101
|
+
part.bytes.length !== endInclusive - start + 1
|
|
102
|
+
)
|
|
103
|
+
throw new Error("Site object changed during transfer");
|
|
104
|
+
yield part.bytes;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function freeze(
|
|
108
|
+
storage: Storage,
|
|
109
|
+
from: string,
|
|
110
|
+
to: string,
|
|
111
|
+
contentType: string,
|
|
112
|
+
optional = false,
|
|
113
|
+
) {
|
|
114
|
+
if (!storage.headObject || !storage.putObjectStreamIfAbsent)
|
|
115
|
+
throw new Error("Object storage cannot freeze Site uploads");
|
|
116
|
+
const existing = await storage.headObject(to);
|
|
117
|
+
if (existing) return existing.ContentLength!;
|
|
118
|
+
const head = await storage.headObject(from);
|
|
119
|
+
if (!head && optional) return null;
|
|
120
|
+
if (!head || !head.ContentLength)
|
|
121
|
+
throw new WorkspaceArtifactOperationError("Upload HTML before publishing the Site");
|
|
122
|
+
if (head.ContentLength > storage.maxSinglePutSizeBytes)
|
|
123
|
+
throw new Error("Site exceeds the storage upload limit");
|
|
124
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
125
|
+
async function* chunks() {
|
|
126
|
+
for await (const part of readArtifactObject(storage, from, head)) {
|
|
127
|
+
decoder.decode(part, { stream: true });
|
|
128
|
+
yield part;
|
|
129
|
+
}
|
|
130
|
+
decoder.decode();
|
|
131
|
+
}
|
|
132
|
+
await storage.putObjectStreamIfAbsent({
|
|
133
|
+
key: to,
|
|
134
|
+
contentType,
|
|
135
|
+
byteSize: head.ContentLength,
|
|
136
|
+
chunks: chunks(),
|
|
137
|
+
});
|
|
138
|
+
return (await storage.headObject(to))!.ContentLength!;
|
|
139
|
+
}
|
|
140
|
+
type Input = Partial<Parameters<typeof prepareWorkspaceArtifactContent>[2]> & { uploadId?: string };
|
|
141
|
+
export async function prepareWorkspaceArtifactPublication(
|
|
142
|
+
deps: ApiRouteDeps,
|
|
143
|
+
grant: AccessGrant,
|
|
144
|
+
input: Input,
|
|
145
|
+
): Promise<PreparedWorkspaceArtifactContent & { uploadId?: string }> {
|
|
146
|
+
const storage = deps.objectStorage;
|
|
147
|
+
if (!storage) throw new Error("Object storage is not configured");
|
|
148
|
+
if (Boolean(input.uploadId) === (input.html !== undefined))
|
|
149
|
+
throw new Error("Provide either uploadId or inline html, not both");
|
|
150
|
+
if (!input.uploadId) {
|
|
151
|
+
return prepareWorkspaceArtifactContent(storage, grant.workspaceId, {
|
|
152
|
+
...input,
|
|
153
|
+
html: input.html!,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (input.source) throw new Error("Upload source JSON using the source upload URL");
|
|
157
|
+
const row = await getWorkspaceArtifactUpload(deps.db, grant.workspaceId, input.uploadId);
|
|
158
|
+
if (
|
|
159
|
+
!row ||
|
|
160
|
+
row.ownerId !== owner(grant) ||
|
|
161
|
+
row.status === "expired" ||
|
|
162
|
+
(row.status === "pending" && row.expiresAt.getTime() <= Date.now())
|
|
163
|
+
)
|
|
164
|
+
throw new WorkspaceArtifactOperationError("Site upload expired or unavailable");
|
|
165
|
+
const paths = keys(grant.workspaceId, row.id);
|
|
166
|
+
const sizeBytes = await freeze(storage, paths.html, paths.frozenHtml, "text/html; charset=utf-8");
|
|
167
|
+
const sourceSizeBytes =
|
|
168
|
+
row.status === "published" && !(await storage.headObject!(paths.frozenSource))
|
|
169
|
+
? null
|
|
170
|
+
: await freeze(storage, paths.source, paths.frozenSource, "application/json", true);
|
|
171
|
+
if (sourceSizeBytes !== null) {
|
|
172
|
+
await validateSiteSource(storage, paths.frozenSource, sourceSizeBytes);
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
uploadId: row.id,
|
|
176
|
+
contentKey: paths.frozenHtml,
|
|
177
|
+
contentSha256: null,
|
|
178
|
+
sizeBytes: sizeBytes!,
|
|
179
|
+
sourceKey: sourceSizeBytes === null ? null : paths.frozenSource,
|
|
180
|
+
sourceSha256: null,
|
|
181
|
+
sourceSizeBytes,
|
|
182
|
+
...(input.requestedTools === undefined ? {} : { requestedTools: input.requestedTools }),
|
|
183
|
+
persistContent: async () => {},
|
|
184
|
+
discardContent: async () => {},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
export async function workspaceArtifactDownloads(
|
|
188
|
+
storage: Storage,
|
|
189
|
+
ref: { contentKey: string; sourceKey: string | null },
|
|
190
|
+
audience: "sandbox" | "public",
|
|
191
|
+
) {
|
|
192
|
+
const [html, source] = await Promise.all([
|
|
193
|
+
storage.createGetUrl({ key: ref.contentKey, audience }),
|
|
194
|
+
ref.sourceKey ? storage.createGetUrl({ key: ref.sourceKey, audience }) : null,
|
|
195
|
+
]);
|
|
196
|
+
return {
|
|
197
|
+
html: { url: html.url, expiresAt: html.expiresAt },
|
|
198
|
+
source: source
|
|
199
|
+
? { url: source.url, expiresAt: source.expiresAt, format: "source-bundle-json" }
|
|
200
|
+
: null,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import {
|
|
3
2
|
WorkspaceArtifactHtml,
|
|
4
3
|
WorkspaceArtifactRequestedTools,
|
|
@@ -15,11 +14,11 @@ const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
|
15
14
|
|
|
16
15
|
export type PreparedWorkspaceArtifactContent = {
|
|
17
16
|
contentKey: string;
|
|
18
|
-
contentSha256:
|
|
17
|
+
contentSha256: null;
|
|
19
18
|
sizeBytes: number;
|
|
20
|
-
sourceKey: string;
|
|
21
|
-
sourceSha256:
|
|
22
|
-
sourceSizeBytes: number;
|
|
19
|
+
sourceKey: string | null;
|
|
20
|
+
sourceSha256: null;
|
|
21
|
+
sourceSizeBytes: number | null;
|
|
23
22
|
requestedTools?: ToolGatewayIdentity[];
|
|
24
23
|
persistContent: () => Promise<void>;
|
|
25
24
|
discardContent: () => Promise<void>;
|
|
@@ -35,31 +34,31 @@ export function prepareWorkspaceArtifactContent(
|
|
|
35
34
|
},
|
|
36
35
|
): PreparedWorkspaceArtifactContent {
|
|
37
36
|
const html = WorkspaceArtifactHtml.parse(input.html);
|
|
38
|
-
const source = WorkspaceArtifactSourceBundle.parse(input.source
|
|
37
|
+
const source = input.source ? WorkspaceArtifactSourceBundle.parse(input.source) : null;
|
|
39
38
|
const requestedTools =
|
|
40
39
|
input.requestedTools === undefined
|
|
41
40
|
? undefined
|
|
42
41
|
: WorkspaceArtifactRequestedTools.parse(input.requestedTools);
|
|
43
42
|
const contentBytes = encoder.encode(html);
|
|
44
|
-
const sourceBytes = encoder.encode(JSON.stringify(source));
|
|
45
|
-
const contentSha256 =
|
|
46
|
-
const sourceSha256 =
|
|
43
|
+
const sourceBytes = source ? encoder.encode(JSON.stringify(source)) : null;
|
|
44
|
+
const contentSha256 = null;
|
|
45
|
+
const sourceSha256 = null;
|
|
47
46
|
const storageGroupId = crypto.randomUUID();
|
|
48
|
-
const contentKey = `workspaces/${workspaceId}/workspace-artifacts/blobs/${storageGroupId}
|
|
49
|
-
const sourceKey = `workspaces/${workspaceId}/workspace-artifacts/sources/${storageGroupId}
|
|
47
|
+
const contentKey = `workspaces/${workspaceId}/workspace-artifacts/blobs/${storageGroupId}.html`;
|
|
48
|
+
const sourceKey = `workspaces/${workspaceId}/workspace-artifacts/sources/${storageGroupId}.json`;
|
|
50
49
|
const discardContent = async (): Promise<void> => {
|
|
51
50
|
await Promise.allSettled([
|
|
52
51
|
objectStorage.deleteObject(contentKey),
|
|
53
|
-
objectStorage.deleteObject(sourceKey),
|
|
52
|
+
...(source ? [objectStorage.deleteObject(sourceKey)] : []),
|
|
54
53
|
]);
|
|
55
54
|
};
|
|
56
55
|
return {
|
|
57
56
|
contentKey,
|
|
58
57
|
contentSha256,
|
|
59
58
|
sizeBytes: contentBytes.byteLength,
|
|
60
|
-
sourceKey,
|
|
59
|
+
sourceKey: source ? sourceKey : null,
|
|
61
60
|
sourceSha256,
|
|
62
|
-
sourceSizeBytes: sourceBytes
|
|
61
|
+
sourceSizeBytes: sourceBytes?.byteLength ?? null,
|
|
63
62
|
...(requestedTools === undefined ? {} : { requestedTools }),
|
|
64
63
|
discardContent,
|
|
65
64
|
persistContent: async () => {
|
|
@@ -68,14 +67,14 @@ export function prepareWorkspaceArtifactContent(
|
|
|
68
67
|
key: contentKey,
|
|
69
68
|
contentType: "text/html; charset=utf-8",
|
|
70
69
|
body: contentBytes,
|
|
71
|
-
sha256: contentSha256,
|
|
72
|
-
}),
|
|
73
|
-
objectStorage.putObject({
|
|
74
|
-
key: sourceKey,
|
|
75
|
-
contentType: "application/json; charset=utf-8",
|
|
76
|
-
body: sourceBytes,
|
|
77
|
-
sha256: sourceSha256,
|
|
78
70
|
}),
|
|
71
|
+
sourceBytes
|
|
72
|
+
? objectStorage.putObject({
|
|
73
|
+
key: sourceKey,
|
|
74
|
+
contentType: "application/json; charset=utf-8",
|
|
75
|
+
body: sourceBytes,
|
|
76
|
+
})
|
|
77
|
+
: Promise.resolve(),
|
|
79
78
|
]);
|
|
80
79
|
const failed = writes.find((write) => write.status === "rejected");
|
|
81
80
|
if (!failed) return;
|
|
@@ -110,24 +109,10 @@ export async function readWorkspaceArtifactContent(
|
|
|
110
109
|
: Promise.resolve(null),
|
|
111
110
|
]);
|
|
112
111
|
if (!contentObject) throw new Error("Artifact content is unavailable");
|
|
113
|
-
if (
|
|
114
|
-
sha256(contentObject.bytes) !== input.version.contentSha256 ||
|
|
115
|
-
contentObject.bytes.byteLength !== input.version.sizeBytes
|
|
116
|
-
) {
|
|
117
|
-
throw new Error("Artifact content failed integrity verification");
|
|
118
|
-
}
|
|
119
112
|
const html = decode(contentObject.bytes, "Artifact content is not valid UTF-8");
|
|
120
113
|
let source = sourceBundleFromHtml(html);
|
|
121
114
|
if (input.sourceKey) {
|
|
122
115
|
if (!sourceObject) throw new Error("Artifact source is unavailable");
|
|
123
|
-
if (
|
|
124
|
-
!input.version.sourceSha256 ||
|
|
125
|
-
input.version.sourceSizeBytes === null ||
|
|
126
|
-
sha256(sourceObject.bytes) !== input.version.sourceSha256 ||
|
|
127
|
-
sourceObject.bytes.byteLength !== input.version.sourceSizeBytes
|
|
128
|
-
) {
|
|
129
|
-
throw new Error("Artifact source failed integrity verification");
|
|
130
|
-
}
|
|
131
116
|
try {
|
|
132
117
|
source = WorkspaceArtifactSourceBundle.parse(
|
|
133
118
|
JSON.parse(decode(sourceObject.bytes, "Artifact source is not valid UTF-8")),
|
|
@@ -150,10 +135,6 @@ export function sourceBundleFromHtml(html: string): WorkspaceArtifactSourceBundl
|
|
|
150
135
|
return { entrypoint: "index.html", files: [{ path: "index.html", content: html }] };
|
|
151
136
|
}
|
|
152
137
|
|
|
153
|
-
function sha256(bytes: Uint8Array): string {
|
|
154
|
-
return createHash("sha256").update(bytes).digest("hex");
|
|
155
|
-
}
|
|
156
|
-
|
|
157
138
|
function decode(bytes: Uint8Array, message: string): string {
|
|
158
139
|
try {
|
|
159
140
|
return decoder.decode(bytes);
|