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
683 changes: 677 additions & 6 deletions apps/hub/src/artifacts/design-draft.test.ts

Large diffs are not rendered by default.

69 changes: 65 additions & 4 deletions apps/hub/src/artifacts/design-draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,72 @@ import { type } from "arktype";
import { workflowRunRepoIdForAddress } from "@intx/hub-sessions";
import { hasCode } from "@intx/types";
import { packApp } from "../builds/app-snapshot.ts";
import {
spliceDesignScreens,
STARTER_HOME_SCREEN,
STARTER_INDEX,
STARTER_SPLICED_INDEX,
} from "../builds/starter.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" };

const STARTER_INDEX = designStarterFiles()[".builder/designs/index.html"];
const hasInlineNonHomeScreen = (index: string | undefined) => {
if (index === undefined) return false;
for (const match of index.matchAll(/data-screen=["']([^"']+)["']/g)) {
if (match[1] !== "home") return true;
}
return false;
};

// The starter's home.html stays on disk when a designer builds screens with other names; drop it
// once another screen exists while home.html is still the starter's.
export function withoutUnusedStarterHome(
files: Record<string, string>,
): Record<string, string> {
const screens = Object.fromEntries(
Object.entries(files).filter(
([path]) => path.startsWith("designs/screens/") && path.endsWith(".html"),
),
);
const next =
files["designs/index.html"] !== undefined && Object.keys(screens).length > 0
? {
...files,
"designs/index.html": spliceDesignScreens(
files["designs/index.html"],
screens,
),
}
: files;
if (next["designs/screens/home.html"] !== STARTER_HOME_SCREEN) return next;
const index = next["designs/index.html"];
const hasOtherScreen =
Object.entries(next).some(
([path, content]) =>
path.startsWith("designs/screens/") &&
path.endsWith(".html") &&
path !== "designs/screens/home.html" &&
content.trim().length > 0,
) || hasInlineNonHomeScreen(index);
if (!hasOtherScreen) return next;
const { "designs/screens/home.html": _, ...rest } = next;
return rest;
}

const isStarterPages = (files: Readonly<Record<string, string>>) => {
const index = files["designs/index.html"];
if (index !== STARTER_INDEX && index !== STARTER_SPLICED_INDEX) return false;
const otherPages = Object.keys(files).filter(
(path) =>
path.endsWith(".html") &&
path !== "designs/index.html" &&
path !== "designs/screens/home.html",
);
if (otherPages.length > 0) return false;
const home = files["designs/screens/home.html"];
return home === undefined || home === STARTER_HOME_SCREEN;
};

// A warm single-step agent's workspace lives at
// <SIDECAR_DATA_DIR>/workflow-step-state/<run repo id>/warm/<step id>/workspace
Expand Down Expand Up @@ -46,9 +106,10 @@ export async function readDesignDraft(workspace: string) {
if (actual !== join(await realpath(workspace), ".builder", "designs"))
return undefined;
const files = await packApp(builder, designs);
const index = files["designs/index.html"];
// The starter mockup is the designer's scaffold, not its draft; the draft begins with its first change.
return index === undefined || index === STARTER_INDEX ? undefined : files;
if (files["designs/index.html"] === undefined || isStarterPages(files))
return undefined;
return withoutUnusedStarterHome(files);
}

const MAX_DESIGN_DOC_BYTES = 256 * 1024;
Expand Down
203 changes: 118 additions & 85 deletions apps/hub/src/artifacts/design-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import { defineTool, type BaseEnv } from "@intx/agent/authoring";
import { HUB_CREDENTIAL_HANDLE } from "@intx/types/credential-delivery";
import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities";
import { DesignBundle } from "@corbits-builder/schemas/artifact";
import { designCheckFor } from "../builds/design-check.ts";
import {
designCheckFor,
registerDesignFiler,
type FiledDesignArtifact,
} from "../builds/design-check.ts";
import { withoutUnusedStarterHome } from "./design-draft.ts";
import { withDesignTokens } from "./design-tokens.ts";
import { DESIGN_STYLES } from "./design-tweaks.ts";
import { resolveWorkflowProjectScope } from "../workflows/project-scope.ts";
Expand Down Expand Up @@ -50,7 +55,7 @@ async function readDesignFolder(workspace: string) {
cwd: join(workspace, ".builder"),
}))
files[path] = await readFile(join(workspace, ".builder", path), "utf8");
return files;
return withoutUnusedStarterHome(files);
}

// Files the designer's .builder folder as its design concept's option. The person decides on the
Expand All @@ -73,97 +78,125 @@ export function createDesignTools() {
credentials: [{ handle: HUB_CREDENTIAL_HANDLE }],
requires: ["toolCwd", "capabilities"],
definitions: [{ name: "file_design_option" }],
factory: (env) => ({
definitions,
run: async (call, signal) => {
if (call.name !== "file_design_option")
return {
callId: call.id,
content: "unknown_design_tool",
isError: true,
};
factory: (env) => {
let lastFiled:
| { content: string; artifact: FiledDesignArtifact }
| undefined;
const fileOption = async (
signal: AbortSignal,
): Promise<FiledDesignArtifact> => {
const files = await readDesignFolder(env.toolCwd);
const doc = files["DESIGN.md"];
const styles = files[DESIGN_STYLES];
if (doc === undefined || styles === undefined)
throw new Error(
`invalid_design: .builder needs DESIGN.md and ${DESIGN_STYLES}`,
);
try {
const scope = await resolveWorkflowProjectScope(
env.capabilities,
"designer",
signal,
files["DESIGN.md"] = withDesignTokens(doc, styles);
} catch (error) {
throw new Error(
`invalid_design: ${error instanceof Error ? error.message : String(error)}`,
);
const projectPath =
"/api/tenants/" +
encodeURIComponent(scope.tenantId) +
"/projects/" +
encodeURIComponent(scope.projectId);
const hub = (path: string, body?: unknown) =>
workflowHttpJson({
capabilities: env.capabilities,
handle: HUB_CREDENTIAL_HANDLE,
path: projectPath + path,
...(body === undefined ? {} : { body }),
maxResponseBytes: 16384,
signal,
});
// The person reviews only a mockup that passed check-app as it is filed.
const check = designCheckFor(await realpath(env.toolCwd));
if (!check)
return {
callId: call.id,
content:
"design_unchecked: check-app has not run on the design yet",
isError: true,
};
const checked = await check.current(signal);
if (!checked.passed)
return {
callId: call.id,
content: `design_check_failed: the design is filed once it passes check-app.\n${checked.checklist}`,
isError: true,
};
const files = await readDesignFolder(env.toolCwd);
const doc = files["DESIGN.md"];
const styles = files[DESIGN_STYLES];
if (doc === undefined || styles === undefined)
}
const bundle = DesignBundle({ files });
if (bundle instanceof type.errors)
throw new Error("invalid_design: " + bundle.summary);
const content = JSON.stringify(bundle);
const bytes = Buffer.byteLength(content);
if (bytes > MAX_DESIGN_BYTES)
throw new Error(
`design_too_large: ${bytes} bytes; the limit is ${MAX_DESIGN_BYTES}`,
);
if (lastFiled?.content === content) return lastFiled.artifact;
const scope = await resolveWorkflowProjectScope(
env.capabilities,
"designer",
signal,
);
const projectPath =
"/api/tenants/" +
encodeURIComponent(scope.tenantId) +
"/projects/" +
encodeURIComponent(scope.projectId);
const title = doc.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? "Design";
const { artifact } = HubArtifactReceipt.assert(
await workflowHttpJson({
capabilities: env.capabilities,
handle: HUB_CREDENTIAL_HANDLE,
path: projectPath + "/artifacts",
body: {
kind: "design_option",
title: title.slice(0, 512),
content,
},
maxResponseBytes: 16384,
signal,
}),
);
lastFiled = { content, artifact };
return artifact;
};
registerDesignFiler(env.toolCwd, fileOption);
return {
definitions,
run: async (call, signal) => {
if (call.name !== "file_design_option")
return {
callId: call.id,
content: `invalid_design: .builder needs DESIGN.md and ${DESIGN_STYLES}`,
content: "unknown_design_tool",
isError: true,
};
try {
files["DESIGN.md"] = withDesignTokens(doc, styles);
const workspace = await realpath(env.toolCwd);
registerDesignFiler(workspace, fileOption);
// The person reviews only a mockup that passed check-app as it is filed.
const check = designCheckFor(workspace);
if (!check)
return {
callId: call.id,
content:
"design_unchecked: check-app has not run on the design yet",
isError: true,
};
const checked = await check.current(signal);
if (!checked.passed) {
if (checked.filingError !== undefined) {
if (
checked.filingError instanceof Error &&
/^(?:invalid_design|design_too_large):/.test(
checked.filingError.message,
)
)
return {
callId: call.id,
content: checked.filingError.message,
isError: true,
};
return failure(call.id, checked.filingError);
}
return {
callId: call.id,
content: `design_check_failed: the design is filed once it passes check-app.\n${checked.checklist}`,
isError: true,
};
}
const artifact = checked.artifact ?? (await fileOption(signal));
return { callId: call.id, content: JSON.stringify(artifact) };
} catch (error) {
return {
callId: call.id,
content: `invalid_design: ${error instanceof Error ? error.message : String(error)}`,
isError: true,
};
if (
error instanceof Error &&
/^(?:invalid_design|design_too_large):/.test(error.message)
)
return {
callId: call.id,
content: error.message,
isError: true,
};
return failure(call.id, error);
}
const bundle = DesignBundle({ files });
if (bundle instanceof type.errors)
return {
callId: call.id,
content: "invalid_design: " + bundle.summary,
isError: true,
};
const content = JSON.stringify(bundle);
const bytes = Buffer.byteLength(content);
if (bytes > MAX_DESIGN_BYTES)
return {
callId: call.id,
content: `design_too_large: ${bytes} bytes; the limit is ${MAX_DESIGN_BYTES}`,
isError: true,
};
const title = doc.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? "Design";
const { artifact } = HubArtifactReceipt.assert(
await hub("/artifacts", {
kind: "design_option",
title: title.slice(0, 512),
content,
}),
);
return { callId: call.id, content: JSON.stringify(artifact) };
} catch (error) {
return failure(call.id, error);
}
},
}),
},
};
},
});
}
23 changes: 14 additions & 9 deletions apps/hub/src/artifacts/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -981,15 +981,20 @@ export function createArtifactService(
concept?: string,
): Promise<DesignDraft> {
await requireProject(actor, projectId, "read");
for (const workspace of await liveWorkspaces(actor, "designer")) {
if ((workspace.concept ?? undefined) !== concept) continue;
const files = await readDesignDraft(workspace.dir);
if (files === undefined) continue;
const draft = DesignDraft.assert({ files });
if (Buffer.byteLength(JSON.stringify(draft)) > MAX_DESIGN_BYTES)
throw new ProjectError(409, "design_draft_too_large");
return draft;
}
for (const ended of [false, true])
for (const workspace of await liveWorkspaces(
actor,
"designer",
ended,
)) {
if ((workspace.concept ?? undefined) !== concept) continue;
const files = await readDesignDraft(workspace.dir);
if (files === undefined) continue;
const draft = DesignDraft.assert({ files });
if (Buffer.byteLength(JSON.stringify(draft)) > MAX_DESIGN_BYTES)
throw new ProjectError(409, "design_draft_too_large");
return draft;
}
throw new ProjectError(404, "design_draft_not_found");
},

Expand Down
14 changes: 14 additions & 0 deletions apps/hub/src/builds/app-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "@corbits-builder/schemas/artifact";
import { WorkflowHttpError, workflowHttpJson } from "../workflows/http-json.ts";
import { INPUTS_DIRECTORY } from "../config/inputs.ts";
import { spliceDesignScreens } from "./starter.ts";

// Installs and builds are rebuilt by setup and check-app, and .builder and the inputs come from
// filed documents.
Expand Down Expand Up @@ -95,6 +96,19 @@ export async function packApp(
}
}
}
if (root === undefined && files["designs/index.html"] !== undefined) {
const screens = Object.fromEntries(
Object.entries(files).filter(
([path]) =>
path.startsWith("designs/screens/") && path.endsWith(".html"),
),
);
if (Object.keys(screens).length > 0)
files["designs/index.html"] = spliceDesignScreens(
files["designs/index.html"],
screens,
);
}
return files;
}

Expand Down
Loading