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
2 changes: 1 addition & 1 deletion apps/hub/src/artifacts/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,7 +691,7 @@ export function mountArtifactRoutes(
);
if (sections instanceof type.errors)
throw new ProjectError(400, "invalid_export_sections", {
issues: sections.summary,
issues: sections,
});
const { filename, archive } = await service.exportArchive(
actorOf(c),
Expand Down
44 changes: 28 additions & 16 deletions apps/hub/src/artifacts/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,11 @@ 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 {
ProjectError,
projectResource,
typeIssues,
} from "../projects/errors.ts";
import { readAppRepository } from "../projects/app-repository.ts";
import {
spawnedWorkflowRole,
Expand Down Expand Up @@ -918,7 +922,7 @@ export function createArtifactService(
const input = DesignTweaks(raw);
if (input instanceof type.errors)
throw new ProjectError(400, "invalid_design_tweaks", {
issues: input.summary,
issues: input,
});
await requireProject(actor, projectId, "manage");
const tokens: Record<string, string> = input.tokens;
Expand Down Expand Up @@ -950,7 +954,7 @@ export function createArtifactService(
const bundle = DesignBundle(parseJson(stored.content));
if (bundle instanceof type.errors)
throw new ProjectError(409, "invalid_design", {
issues: bundle.summary,
issues: bundle,
});
const files = {
...bundle.files,
Expand Down Expand Up @@ -1086,7 +1090,7 @@ export function createArtifactService(
const material = ProjectExportMaterial(input);
if (material instanceof type.errors)
throw new ProjectError(400, "invalid_material", {
issues: material.summary,
issues: material,
});
const allowed = await requireProject(actor, projectId, "manage");
if (!(await allowed(artifactResource(MATERIAL_KIND), "write")))
Expand Down Expand Up @@ -1157,7 +1161,7 @@ export function createArtifactService(
const input = ArtifactRead(raw);
if (input instanceof type.errors)
throw new ProjectError(400, "invalid_artifact_read", {
issues: input.summary,
issues: input,
});
await requireProject(actor, projectId, "read");
const pinned = input.artifactId?.match(PINNED_REF);
Expand Down Expand Up @@ -1252,27 +1256,35 @@ export function createArtifactService(
const input = ArtifactKindWrite(raw);
if (input instanceof type.errors)
throw new ProjectError(400, "invalid_artifact_write", {
issues: input.summary,
issues: input,
});
const bundled =
input.kind === "design" ||
input.kind === "design_option" ||
input.kind === "app";
if (bundled && input.openQuestions)
throw new ProjectError(400, "invalid_artifact_write", {
issues: "openQuestions belong to documents, not a " + input.kind,
issues: [
{
path: "openQuestions",
message: `goes on documents; write this ${input.kind} without it`,
},
],
});
if (input.approaches) {
if (input.kind !== "approaches")
throw new ProjectError(400, "invalid_artifact_write", {
issues:
"approaches belong to the approaches document, not a " +
input.kind,
issues: [
{
path: "approaches",
message: `goes on the approaches document; write this ${input.kind} without it`,
},
],
});
const issue = approachesIssue(input.approaches);
if (issue)
throw new ProjectError(400, "invalid_artifact_write", {
issues: issue,
issues: [issue],
});
}
if (bundled) {
Expand All @@ -1281,7 +1293,7 @@ export function createArtifactService(
);
if (bundle instanceof type.errors)
throw new ProjectError(400, "invalid_" + input.kind, {
issues: bundle.summary,
issues: typeIssues(bundle, "content"),
});
}
if (input.kind === "app") {
Expand All @@ -1295,7 +1307,7 @@ export function createArtifactService(
const document = DocumentContent(input.content);
if (document instanceof type.errors)
throw new ProjectError(400, "invalid_artifact_write", {
issues: document.summary,
issues: typeIssues(document, "content"),
});
}
const allowed = await requireProject(actor, projectId, "read");
Expand Down Expand Up @@ -1699,7 +1711,7 @@ export function createArtifactService(
const input = DesignConceptRecord(raw);
if (input instanceof type.errors)
throw new ProjectError(400, "invalid_design_concept", {
issues: input.summary,
issues: input,
});
await requireProject(actor, projectId, "manage");
const [run] = await rawDB
Expand Down Expand Up @@ -1779,7 +1791,7 @@ export function createArtifactService(
const input = ArtifactApprovalRecord(raw);
if (input instanceof type.errors)
throw new ProjectError(400, "invalid_artifact_approval", {
issues: input.summary,
issues: input,
});
await requireProject(actor, projectId, "manage");
const covers = APPROVAL_COVERS[input.tool]!;
Expand Down Expand Up @@ -1886,7 +1898,7 @@ export function createArtifactService(
const input = DesignChoice(raw);
if (input instanceof type.errors)
throw new ProjectError(400, "invalid_design_choice", {
issues: input.summary,
issues: input,
});
await requireProject(actor, projectId, "manage");
const [chooser] = await rawDB
Expand Down
207 changes: 207 additions & 0 deletions apps/hub/src/projects/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { expect, test } from "bun:test";
import { type } from "arktype";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { Hono } from "hono";
import type { TenantEnv } from "@intx/hub-api";
import {
ArtifactKindWrite,
DesignBundle,
DesignTweaks,
} from "@corbits-builder/schemas/artifact";
import { ProjectExportMaterial } from "@corbits-builder/schemas/project";
import { mountArtifactRoutes } from "../artifacts/routes.ts";
import { createArtifactService } from "../artifacts/service.ts";
import { createArtifactTools } from "../artifacts/tools.ts";
import { ProjectError, typeIssues } from "./errors.ts";

const deployment = {
source: {
kind: "asset" as const,
assetId: "asset",
package: { format: "tarball" as const },
},
pin: "@corbits-builder/writer@1.0.0",
entry: "entry",
sourceOfferingIds: ["offering"],
defaultSourceOfferingId: "offering",
};

const approach = (id: string) => ({
id,
name: `Approach ${id}`,
summary: "A way to build it.",
bestFor: "Trying it out.",
pros: ["Quick"],
cons: ["Limited"],
effort: "low",
risks: [],
});

test("a rejected write_artifact call reaches the agent with {path, message} issues", async () => {
const service = createArtifactService(
Object.create(PostgresJsDatabase.prototype),
{} as never,
);
const app = new Hono<TenantEnv>();
app.onError((error, c) => {
if (error instanceof ProjectError)
return c.json({ code: error.code, ...error.details }, error.status);
throw error;
});
app.use("*", async (c, next) => {
c.set("tenant", { id: "tenant" } as never);
c.set("principal", { id: "principal" } as never);
await next();
});
const projects = new Hono<TenantEnv>();
mountArtifactRoutes(projects, service);
app.route("/api/tenants/:tenantId/projects", projects);
app.get("/api/workflow-project-scope", (c) =>
c.json({
tenantId: "tenant",
projectId: "p-00000000000000000000000000000000",
domain: "example.invalid",
role: "writer",
workflows: {
writer: deployment,
reviewer: deployment,
coder: deployment,
designer: deployment,
},
openNetwork: false,
build: {
assetDirectory: "/a",
browserCli: "/b",
childPath: "/c",
dependencyRoots: ["/d"],
ripgrepPath: "/r",
runtimeVersion: "1",
},
}),
);
const tools = createArtifactTools("writer")({
capabilities: {
resolve: () => ({
resolve: async () => ({
kind: "http",
fetch: (path: string, init?: RequestInit) =>
app.fetch(new Request("http://hub.local" + path, init)),
}),
}),
},
} as never);
const write = async (args: Record<string, unknown>) => {
const result = await tools.run(
{
id: "call",
name: "write_artifact",
arguments: { title: "Doc", content: "# Doc\n\nBody.", ...args },
},
new AbortController().signal,
);
expect(result.isError).toBe(true);
return JSON.parse(String(result.content));
};

expect(
await write({
kind: "problem_brief",
approaches: { approaches: [approach("a"), approach("b")] },
}),
).toEqual({
code: "invalid_artifact_write",
status: 400,
issues: [
{
path: "approaches",
message:
"goes on the approaches document; write this problem_brief without it",
},
],
});
expect(
await write({
kind: "approaches",
approaches: { approaches: [approach("a"), approach("a")] },
}),
).toEqual({
code: "invalid_artifact_write",
status: 400,
issues: [
{
path: "approaches.approaches",
message: "give each approach a unique id",
},
],
});
expect(
await write({
kind: "approaches",
approaches: {
approaches: [approach("a"), approach("b")],
recommended: "c",
},
}),
).toEqual({
code: "invalid_artifact_write",
status: 400,
issues: [
{
path: "approaches.recommended",
message: "name one of the approach ids",
},
],
});
});

test("typeIssues names the rule for narrow checks, unions and extra keys without quoting submitted values", () => {
const secret = "sk-live-secret-value-not-for-issues";
const bundle = DesignBundle({ files: { "notes.txt": secret } });
const tweaks = DesignTweaks({ tokens: {} });
const write = ArtifactKindWrite({
kind: secret,
title: "Title",
content: "Body",
extra: secret,
});
const material = ProjectExportMaterial({
title: "file",
content: secret.repeat(8000),
});
if (
!(bundle instanceof type.errors) ||
!(tweaks instanceof type.errors) ||
!(write instanceof type.errors) ||
!(material instanceof type.errors)
)
throw new Error("expected validation errors");

expect(typeIssues(bundle, "content")).toEqual([
{
path: "content.files",
message: "must be a bundle with DESIGN.md and designs/index.html",
},
]);
expect(typeIssues(tweaks)).toEqual([
{ path: "tokens", message: "must be at least one token" },
]);
expect(typeIssues(write)).toEqual([
{
path: "kind",
message:
'must be "app", "approaches", "build_plan", "constraints", "design", "design_option", "problem_brief" or "product_requirements"',
},
{ path: "extra", message: "is not allowed here" },
]);
expect(typeIssues(material)).toEqual([
{ path: "content", message: "is not one of the allowed values" },
]);
expect(
JSON.stringify([
typeIssues(bundle, "content"),
typeIssues(tweaks),
typeIssues(write),
typeIssues(material),
]),
).not.toContain(secret);
});
Loading