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
22 changes: 6 additions & 16 deletions apps/hub/src/artifacts/design-draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { open, realpath } from "node:fs/promises";
import { dirname, join } from "node:path";
import { type } from "arktype";
import { workflowRunRepoIdForAddress } from "@intx/hub-sessions";
import { hasCode } from "@intx/types";
import { packApp } from "../builds/app-snapshot.ts";
import { parseJson } from "../lib/json.ts";
import { ProjectError } from "../projects/errors.ts";
import { DESIGN_STYLES } from "./design-tweaks.ts";
import { designStarterFiles } from "@corbits-builder/designer/skills" with { type: "macro" };
Expand All @@ -28,9 +30,6 @@ export function warmStepWorkspace(
);
}

const isMissing = (error: unknown) =>
error instanceof Error && "code" in error && error.code === "ENOENT";

// The mockup the designer is writing, keyed as the design bundle keys it (designs/...), or
// undefined until it has an index.html. Neither .builder nor designs may be a symlink, and
// packApp reads only regular files, so nothing outside .builder/designs is served.
Expand All @@ -41,7 +40,7 @@ export async function readDesignDraft(workspace: string) {
try {
actual = await realpath(designs);
} catch (error) {
if (isMissing(error)) return undefined;
if (hasCode(error) && error.code === "ENOENT") return undefined;
throw error;
}
if (actual !== join(await realpath(workspace), ".builder", "designs"))
Expand Down Expand Up @@ -89,17 +88,14 @@ async function readWorkspaceText(
)
return undefined;
} catch (error) {
if (isMissing(error)) return undefined;
if (hasCode(error) && error.code === "ENOENT") return undefined;
throw error;
}
let file;
try {
file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
} catch (error) {
if (
isMissing(error) ||
(error instanceof Error && "code" in error && error.code === "ELOOP")
)
if (hasCode(error) && (error.code === "ENOENT" || error.code === "ELOOP"))
return undefined;
throw error;
}
Expand Down Expand Up @@ -165,13 +161,7 @@ export function demoLogin(files: Readonly<Record<string, string>>) {
const text = files[DEMO_ACCOUNT];
if (text === undefined || text.length > MAX_DEMO_ACCOUNT_BYTES)
return undefined;
let json: unknown;
try {
json = JSON.parse(text);
} catch {
return undefined;
}
const account = DemoAccount(json);
const account = DemoAccount(parseJson(text));
return account instanceof type.errors
? undefined
: { email: account.email, password: account.password };
Expand Down
5 changes: 3 additions & 2 deletions apps/hub/src/artifacts/materials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@ export function materialMimeType(file: { name: string; type: string }): string {
function utf8(bytes: Uint8Array): string | undefined {
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return undefined;
} catch (error) {
if (error instanceof TypeError) return undefined;
throw error;
}
}

Expand Down
19 changes: 11 additions & 8 deletions apps/hub/src/artifacts/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from "@corbits-builder/schemas/project";
import { actorOf } from "../projects/authorization.ts";
import { ProjectError } from "../projects/errors.ts";
import { readJson } from "../lib/json.ts";
import { mailRunAsCaller } from "../mail/caller-mail.ts";
import type { createArtifactService } from "./service.ts";

Expand All @@ -51,11 +52,9 @@ export function mountArtifactRoutes(
nativeFetch?: (request: Request) => Promise<Response>,
) {
const body = async (c: { req: { json(): Promise<unknown> } }) => {
try {
return await c.req.json();
} catch {
throw new ProjectError(400, "invalid_json");
}
const json = await readJson(c.req);
if (json === undefined) throw new ProjectError(400, "invalid_json");
return json;
};
const failure = (
c: {
Expand Down Expand Up @@ -419,9 +418,13 @@ export function mountArtifactRoutes(
async (c) => {
try {
const current = actorOf(c);
const form = await c.req.formData().catch(() => {
throw new ProjectError(400, "invalid_form");
});
let form: FormData;
try {
form = await c.req.formData();
} catch (error) {
if (!(error instanceof TypeError)) throw error;
throw new ProjectError(400, "invalid_form", {}, { cause: error });
}
const message = form.get("message");
return c.json(
await service.attachMaterials(
Expand Down
58 changes: 31 additions & 27 deletions apps/hub/src/artifacts/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ import {
} from "@corbits/artifacts";
import type { DB } from "@intx/db";
import { getLogger } from "@intx/log";
import { base64Decode, base64Encode, validateAttachments } from "@intx/types";
import {
base64Decode,
base64Encode,
hasCode,
validateAttachments,
} from "@intx/types";
import {
WORKFLOW_RUN_REF,
workflowRunRepoIdForAddress,
Expand Down Expand Up @@ -89,6 +94,7 @@ import {
} from "../db/schema.ts";
import type { AgentActivityTracker } from "./activity.ts";
import type { Actor } from "../lib/actor.ts";
import { parseJson } from "../lib/json.ts";
import { authorizeProject } from "../projects/authorization.ts";
import { ProjectError, projectResource } from "../projects/errors.ts";
import { readAppRepository } from "../projects/app-repository.ts";
Expand Down Expand Up @@ -162,19 +168,20 @@ const appTooLarge = (error: unknown) =>
})
: error;

const parseBundle = (content: string): unknown => {
try {
return JSON.parse(content);
} catch {
return undefined;
}
};

// 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) =>
!path.startsWith("/") && !path.split(/[\\/]/).includes("..");

async function isDirectory(path: string) {
try {
return (await stat(path)).isDirectory();
} catch (error) {
if (hasCode(error) && error.code === "ENOENT") return false;
throw error;
}
}

const artifactResource = (kind: string) => "sb-artifact:" + kind;

// A document version records the open questions it leaves for the person; a version filed without
Expand Down Expand Up @@ -359,13 +366,7 @@ export function createArtifactService(
if (!sidecarDataDir || run.address === null)
return unavailable("not_on_local_sidecar");
const dir = warmStepWorkspace(sidecarDataDir, run.address, run.stepId);
if (
!(await stat(dir).then(
(found) => found.isDirectory(),
() => false,
))
)
return unavailable("workspace_missing");
if (!(await isDirectory(dir))) return unavailable("workspace_missing");
if (!(await Bun.file(join(dir, "package.json")).exists()))
return unavailable("package_json_missing");
return { runId: run.runId, dir };
Expand Down Expand Up @@ -465,7 +466,7 @@ export function createArtifactService(
return stored
? {
name: `v${filed.version}`,
bundle: AppBundle.assert(parseBundle(stored.content)),
bundle: AppBundle.assert(parseJson(stored.content)),
}
: undefined;
}
Expand Down Expand Up @@ -945,7 +946,8 @@ export function createArtifactService(
throw new ProjectError(404, "design_not_found");
if (filed) {
const stored = await getArtifactVersion(db, filed.id, filed.version);
const bundle = DesignBundle(parseBundle(stored?.content ?? ""));
if (!stored) throw new ProjectError(409, "invalid_design");
const bundle = DesignBundle(parseJson(stored.content));
if (bundle instanceof type.errors)
throw new ProjectError(409, "invalid_design", {
issues: bundle.summary,
Expand Down Expand Up @@ -1275,7 +1277,7 @@ export function createArtifactService(
}
if (bundled) {
const bundle = (input.kind === "app" ? AppBundle : DesignBundle)(
parseBundle(input.content),
parseJson(input.content),
);
if (bundle instanceof type.errors)
throw new ProjectError(400, "invalid_" + input.kind, {
Expand Down Expand Up @@ -1650,7 +1652,7 @@ export function createArtifactService(
zip.file(`planning/${artifact.kind}.md`, stored.content);
} else {
const files = Object.entries(
DesignBundle.assert(parseBundle(stored.content)).files,
DesignBundle.assert(parseJson(stored.content)).files,
);
if (artifact.kind === "design") add("design", files);
else if (isContainedPath(stored.title))
Expand Down Expand Up @@ -1730,16 +1732,18 @@ export function createArtifactService(
} catch (error) {
// The coordinator's token only reads runs, so a designer left without its concept would
// hold a slot as a waiting designer; the hub stops it and the original error stands.
await cancelRun?.(
actor.tenantId,
input.runId,
"design_concept_unrecorded",
).catch((cancelError: unknown) =>
try {
await cancelRun?.(
actor.tenantId,
input.runId,
"design_concept_unrecorded",
);
} catch (cancelError) {
log.warn("Stopping designer run {runId} without its concept failed", {
runId: input.runId,
...requestErrorMetadata(cancelError),
}),
);
});
}
throw error;
}
const [stored] = await rawDB
Expand Down
8 changes: 3 additions & 5 deletions apps/hub/src/builds/package-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@ import { readFile, realpath } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
import { hasCode } from "@intx/types";
import { rgPath } from "@vscode/ripgrep";

const isMissing = (error: unknown) =>
error instanceof Error && "code" in error && error.code === "ENOENT";

// Resolve with Bun's installed package graph; source closures and npm installs
// can place the same dependency outside the importing package's node_modules.
async function packageRoot(name: string, from: string) {
Expand All @@ -18,7 +16,7 @@ async function packageRoot(name: string, from: string) {
);
if (manifest.name === name) return { directory, manifest };
} catch (error) {
if (!isMissing(error)) throw error;
if (!(hasCode(error) && error.code === "ENOENT")) throw error;
}
const parent = dirname(directory);
if (parent === directory)
Expand Down Expand Up @@ -62,7 +60,7 @@ export async function buildPackageLayout() {
if ((await realpath(join(path, name))) === entry.directory)
roots.add(await realpath(path));
} catch (error) {
if (!isMissing(error)) throw error;
if (!(hasCode(error) && error.code === "ENOENT")) throw error;
}
}
if (known) continue;
Expand Down
21 changes: 21 additions & 0 deletions apps/hub/src/lib/json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/** A body's JSON, or undefined when it is not JSON; any other read failure propagates. */
export async function readJson(body: {
json(): Promise<unknown>;
}): Promise<unknown> {
try {
return await body.json();
} catch (error) {
if (error instanceof SyntaxError) return undefined;
throw error;
}
}

/** The text's JSON, or undefined when it is not JSON. */
export function parseJson(text: string): unknown {
try {
return JSON.parse(text);
} catch (error) {
if (error instanceof SyntaxError) return undefined;
throw error;
}
}
14 changes: 8 additions & 6 deletions apps/hub/src/lib/serial-queue.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
// The caller observes the failure; the queue only needs ordering.
export async function settle(
operation: Promise<unknown> | undefined,
): Promise<void> {
try {
await operation;
} catch {
// The caller observes the failure; the queue only needs ordering.
}
await Promise.allSettled([operation]);
}

async function runAfter<T>(previous: Promise<void>, task: () => Promise<T>) {
await previous;
return task();
}

export function createSerialQueue() {
let tail: Promise<void> = Promise.resolve();
return {
enqueue<T>(task: () => Promise<T>): Promise<T> {
const result = tail.then(task);
const result = runAfter(tail, task);
tail = settle(result);
return result;
},
Expand Down
Loading