Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions apps/hub/src/artifacts/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import {
DesignTweaks,
DesignTweaksReceipt,
LiveApp,
MAX_APPROVAL_PACKAGE_BYTES,
MaterialReceipt,
MaterialRemovedReceipt,
} from "@corbits-builder/schemas/artifact";
Expand Down Expand Up @@ -127,10 +126,6 @@ export function mountArtifactRoutes(
);
app.post(
"/:projectId/artifacts",
bodyLimit({
maxSize: MAX_APPROVAL_PACKAGE_BYTES + 1024 * 1024,
onError: (c) => c.json({ code: "artifact_too_large" }, 413),
}),
describeRoute({
tags: ["Artifacts"],
operationId: "writeProjectArtifact",
Expand Down Expand Up @@ -600,10 +595,6 @@ export function mountArtifactRoutes(
description:
"The newest coder run's workspace cannot be read by this hub (app_workspace_unavailable, with its reason); an older run's workspace is never taken instead",
},
413: {
description:
"The app is larger than an app version holds (app_too_large)",
},
},
}),
async (c) => {
Expand Down
57 changes: 10 additions & 47 deletions apps/hub/src/artifacts/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ import {
AppBundle,
AppVersionMetadata,
DocumentContent,
MAX_APP_BYTES,
MAX_APPROVAL_PACKAGE_BYTES,
MATERIAL_KIND,
ArtifactKindWrite,
Expand Down Expand Up @@ -125,11 +124,7 @@ import {
warmStepWorkspace,
} from "./design-draft.ts";
import type { LiveApps } from "../live-app/runner.ts";
import {
AppTooLargeError,
appContent,
bundleFiles,
} from "../builds/app-snapshot.ts";
import { appContent, bundleFiles } from "../builds/app-snapshot.ts";
import { MAX_DESIGN_BYTES } from "./design-tools.ts";
import { DESIGN_STYLES, setThemeTokens } from "./design-tweaks.ts";
import {
Expand Down Expand Up @@ -177,8 +172,6 @@ const APPROVAL_COVERS: Record<string, (kind: string) => boolean> = {
kind !== "app" && kind !== "design" && kind !== "design_option",
instruct_coder: (kind) => kind !== "app" && kind !== "design_option",
};
// Whole documents, the filed app and approval package among them.
const MAX_READ_CHARS = MAX_APPROVAL_PACKAGE_BYTES;
const RECENT_FAILURES = 5;
// A command's progress line older than this was left by a command that died; it is not shown.
const COMMAND_PROGRESS_STALE_MS = 5 * 60_000;
Expand All @@ -197,14 +190,6 @@ const isUuid = (value: string) => new RegExp("^" + UUID + "$", "i").test(value);
// The coder task names documents as id@version@sha256 tokens.
const PINNED_REF = new RegExp("^(" + UUID + ")@(\\d+)@([a-f0-9]{64})$", "i");

const appTooLarge = (error: unknown) =>
error instanceof AppTooLargeError
? new ProjectError(413, "app_too_large", {
bytes: error.bytes,
limit: MAX_APP_BYTES,
})
: error;

// An imported bundle's paths and a material's filename are the importer's text: none may climb
// out of the directory it is archived under.
const isContainedPath = (path: string) =>
Expand Down Expand Up @@ -267,22 +252,16 @@ function validateArtifactWriteContent(
issues: typeIssues(bundle, "content"),
});
}
if (input.kind === "app") {
const bytes = Buffer.byteLength(input.content);
if (bytes > MAX_APP_BYTES)
throw new ProjectError(413, "app_too_large", {
bytes,
limit: MAX_APP_BYTES,
});
} else if (input.kind === "approval_package") {
// An app is as large as it is.
if (input.kind === "approval_package") {
const bytes = Buffer.byteLength(input.content);
if (bytes > MAX_APPROVAL_PACKAGE_BYTES)
throw new ProjectError(413, "approval_package_too_large", {
bytes,
limit: MAX_APPROVAL_PACKAGE_BYTES,
});
parseApprovalPackageBundle(input.content);
} else {
} else if (input.kind !== "app") {
const document = DocumentContent(input.content);
if (document instanceof type.errors)
throw new ProjectError(400, "invalid_artifact_write", {
Expand Down Expand Up @@ -554,16 +533,11 @@ export function createArtifactService(
await listArtifacts(db, actor.tenantId, { kind: "app", limit: 1 })
).rows;
const filed = latest?.contentSha256 ? latest : undefined;
let repository;
try {
repository = await newerRepositoryApp(
actor,
projectId,
filed && (await filedAt(filed.id, filed.version)),
);
} catch (error) {
throw appTooLarge(error);
}
const repository = await newerRepositoryApp(
actor,
projectId,
filed && (await filedAt(filed.id, filed.version)),
);
if (repository)
return {
name: repository.commit.slice(0, 7),
Expand Down Expand Up @@ -1447,12 +1421,6 @@ export function createArtifactService(
artifactId,
version,
});
if (stored.content.length > MAX_READ_CHARS)
throw new ProjectError(409, "artifact_too_large", {
artifactId,
contentLength: stored.content.length,
limit: MAX_READ_CHARS,
});
const { openQuestions = [], approaches } = documentMetadataOf(
stored.metadata,
);
Expand Down Expand Up @@ -1714,12 +1682,7 @@ export function createArtifactService(
runId: workspace.runId,
reason: workspace.reason,
});
let content: string;
try {
content = await appContent(workspace.dir);
} catch (error) {
throw appTooLarge(error);
}
const content = await appContent(workspace.dir);
const metadata = AppVersionMetadata.assert({
source: "snapshot",
runId: workspace.runId,
Expand Down
6 changes: 2 additions & 4 deletions apps/hub/src/artifacts/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
ArtifactList,
ArtifactRead,
ArtifactReceipt,
MAX_APPROVAL_PACKAGE_BYTES,
WriterDocumentFields,
type DocumentWrite,
type WriterDocumentKind,
Expand All @@ -30,8 +29,6 @@ interface ArtifactToolEnv extends BaseEnv {
capabilities: RuntimeCapabilities;
}

const MAX_RESULT_BYTES = MAX_APPROVAL_PACKAGE_BYTES + 256 * 1024;

// A deployed agent keeps the tool bundle it started with while the hub moves on; a field a newer
// hub adds must not fail every read of an older agent.
export const HubArtifactList = ArtifactList.onDeepUndeclaredKey("ignore");
Expand Down Expand Up @@ -181,7 +178,8 @@ function artifactTools(
handle: HUB_CREDENTIAL_HANDLE,
path,
...(body === undefined ? {} : { body }),
maxResponseBytes: MAX_RESULT_BYTES,
// A document is as large as it is; the parts below keep the model's read small.
maxResponseBytes: Number.POSITIVE_INFINITY,
signal,
});
if (call.name === "write_artifact" && save) {
Expand Down
122 changes: 51 additions & 71 deletions apps/hub/src/builds/app-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,6 @@ const PERMANENT =
// files changed since the check.
export const CHECK_PASSED = "Check passed";
export const EDIT = "Edit";
// The repository carries the app's binary assets, such as images in public/, which the 1 MiB text
// artifact of the app leaves out.
const APP_REPOSITORY_MAX_BYTES = 25 * 1024 * 1024;
// A pkt-line holds at most 65520 bytes: its 4-byte length, the band byte and the data.
const SIDE_BAND_DATA = 65515;
const log = getLogger(["corbits-builder", "hub", "app-repository"]);
Expand Down Expand Up @@ -227,8 +224,6 @@ export async function signWithSidecarKey(payload: string) {
);
}

class AppTooLarge extends Error {}

type Blob = { mode: string; oid: string };
type Tree = Map<string, Blob | Tree>;
type WorkspaceFile = { bytes: Buffer; executable: boolean };
Expand Down Expand Up @@ -283,11 +278,6 @@ export function createAppRepository(options: {
password: secret,
});

const tooLarge = (filepath: string) =>
new AppTooLarge(
`app_too_large: the app's files pass the ${APP_REPOSITORY_MAX_BYTES}-byte limit at ${filepath}`,
);

// Whether a directory's real location is still the one the walk reached it by.
const inPlace = async (root: string, directory: string) =>
(await realpath(directory)) === join(root, relative(workspace, directory));
Expand Down Expand Up @@ -326,7 +316,6 @@ export function createAppRepository(options: {
root: string,
path: string,
filepath: string,
room: number,
): Promise<WorkspaceFile | undefined> {
let file;
try {
Expand All @@ -347,9 +336,7 @@ export function createAppRepository(options: {
if (linkOrGone(error)) return undefined;
throw error;
}
if (stats.size > room) throw tooLarge(filepath);
const bytes = await file.readFile();
if (bytes.length > room) throw tooLarge(filepath);
if (!(await readInPlace(file.fd, root, path, stats)))
throw new Error(
`app_workspace_changed: ${filepath} moved while it was read`,
Expand Down Expand Up @@ -400,17 +387,9 @@ export function createAppRepository(options: {
// The workspace's own files by path.
async function collect(root: string, signal: AbortSignal) {
const files = new Map<string, WorkspaceFile>();
let bytes = 0;
await walk(root, workspace, signal, async (path, filepath) => {
const file = await readRegular(
root,
path,
filepath,
APP_REPOSITORY_MAX_BYTES - bytes,
);
if (!file) return;
bytes += file.bytes.length;
files.set(filepath, file);
const file = await readRegular(root, path, filepath);
if (file) files.set(filepath, file);
});
return files;
}
Expand Down Expand Up @@ -633,58 +612,64 @@ export function createAppRepository(options: {

// The workspace's fingerprint at its last version that reached the hub.
let versioned: string | undefined;
// The workspace's fingerprint when its files passed the size limit; it is not read again unchanged.
let refused: string | undefined;

// The fingerprint a check passed on, or undefined once the check failed: its files are then an
// edit, not a passed check.
const passedFiles = async (checked: Promise<string>) => {
try {
return await checked;
} catch (error) {
log.debug("The check did not pass, so the files are an edit: {reason}", {
reason: error instanceof Error ? error.message : String(error),
});
return undefined;
}
};

// Commits the workspace when it differs from main, then pushes main if the hub lacks it. A
// workspace unchanged since its last version, as after a read-only command, is left alone, and
// one unchanged since it was refused as too large is skipped, keeping that failure. A check's
// label names only a commit of the files it passed on: unchanged since the check, and while read.
// workspace unchanged since its last version, as after a read-only command, is left alone. A
// check's label names only a commit of the files it passed on: unchanged since the check, and
// while read.
const version = (
reason: string,
checked: Promise<string> | undefined,
signal: AbortSignal,
): Promise<{ commit: string | undefined } | undefined> =>
): Promise<{ commit: string | undefined }> =>
serial(async () => {
const files = await fingerprint(signal);
if (files === versioned) return { commit: undefined };
if (files === refused) return undefined;
let created: string | undefined;
try {
await attach(signal, false);
const head = await headCommit();
const { tree, blobs } = await snapshot(signal);
if (tree !== head.tree) {
const label =
checked === undefined ||
((await checked.catch(() => undefined)) === files &&
(await fingerprint(signal)) === files)
? reason
: EDIT;
const { author } = await pushAccess(signal);
const { title, body } = summary(await flatten(head.tree), blobs);
const identity = {
...author,
timestamp: Math.floor(Date.now() / 1000),
};
created = await git.commit({
fs,
gitdir,
ref: MAIN,
tree,
parent: [head.oid],
author: identity,
committer: identity,
message: `${label}: ${title}\n\n${body}\n`,
signingKey: "sshsig",
onSign: async ({ payload }) => ({
signature: await options.sign(payload),
}),
});
}
} catch (error) {
if (error instanceof AppTooLarge) refused = files;
throw error;
await attach(signal, false);
const head = await headCommit();
const { tree, blobs } = await snapshot(signal);
if (tree !== head.tree) {
let label = EDIT;
if (checked === undefined) label = reason;
else if (
(await passedFiles(checked)) === files &&
(await fingerprint(signal)) === files
)
label = reason;
const { author } = await pushAccess(signal);
const { title, body } = summary(await flatten(head.tree), blobs);
const identity = {
...author,
timestamp: Math.floor(Date.now() / 1000),
};
created = await git.commit({
fs,
gitdir,
ref: MAIN,
tree,
parent: [head.oid],
author: identity,
committer: identity,
message: `${label}: ${title}\n\n${body}\n`,
signingKey: "sshsig",
onSign: async ({ payload }) => ({
signature: await options.sign(payload),
}),
});
}
await push(signal);
versioned = files;
Expand Down Expand Up @@ -759,12 +744,7 @@ export function createAppRepository(options: {
true,
);
} catch (error) {
if (
attempt === CLONE_ATTEMPTS ||
stopping.aborted ||
error instanceof AppTooLarge
)
throw error;
if (attempt === CLONE_ATTEMPTS || stopping.aborted) throw error;
}
await sleep(CLONE_RETRY_MS, undefined, { signal: stopping });
}
Expand Down
Loading