diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 070ca56f..50419b5a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -142,7 +142,8 @@ change follows, whether a person or a coding agent writes it. ## Releasing Maintainers bump `version` in the root, `apps/hub`, `apps/web`, -`packages/client` and `packages/schemas` package.json files, run +`packages/client`, `packages/schemas`, `packages/tools-design` and +`packages/tools-hub` package.json files, run `bun install`, and merge. Once the publish workflow for that commit on main has finished, `git tag -s vX.Y.Z && git push origin vX.Y.Z` retags its hub and sidecar images as `X.Y.Z` and publishes the GitHub release; a 0.x version or diff --git a/apps/hub/package.json b/apps/hub/package.json index c39cedd8..965ac9d6 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -20,6 +20,8 @@ "@corbits-builder/designer": "workspace:*", "@corbits-builder/reviewer": "workspace:*", "@corbits-builder/schemas": "workspace:*", + "@corbits-builder/tools-design": "workspace:*", + "@corbits-builder/tools-hub": "workspace:*", "@corbits-builder/writer": "workspace:*", "@corbits/agent-token": "0.2.2", "@corbits/artifacts": "0.3.0", diff --git a/apps/hub/src/artifacts/design-bundle.ts b/apps/hub/src/artifacts/design-bundle.ts new file mode 100644 index 00000000..c79bd3d5 --- /dev/null +++ b/apps/hub/src/artifacts/design-bundle.ts @@ -0,0 +1,28 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { dirname, join, sep } from "node:path"; +import { DesignBundle } from "@corbits-builder/schemas/artifact"; + +// Writes a bundle's files under root, skipping any path that would land outside it. +export async function writeBundle( + root: string, + files: Readonly>, +) { + const written: Record = {}; + for (const [path, data] of Object.entries(files)) { + const target = join(root, path); + if (!target.startsWith(root + sep)) continue; + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, data); + written[path] = data; + } + return written; +} + +// The coder builds against the design version its task names. It lands in .builder, which the +// coder's sandbox keeps read-only, so only the designer changes it. +export async function unpackDesign(content: string, workspace: string) { + const { files } = DesignBundle.assert(JSON.parse(content)); + const root = join(workspace, ".builder"); + await rm(root, { recursive: true, force: true }); + return writeBundle(root, files); +} diff --git a/apps/hub/src/artifacts/design-draft.ts b/apps/hub/src/artifacts/design-draft.ts index e2cba032..161ff919 100644 --- a/apps/hub/src/artifacts/design-draft.ts +++ b/apps/hub/src/artifacts/design-draft.ts @@ -7,7 +7,7 @@ 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 { DESIGN_STYLES } from "@corbits-builder/tools-design"; import { designStarterFiles } from "@corbits-builder/designer/skills" with { type: "macro" }; const STARTER_INDEX = designStarterFiles()[".builder/designs/index.html"]; diff --git a/apps/hub/src/artifacts/design-tools.ts b/apps/hub/src/artifacts/design-tools.ts deleted file mode 100644 index 91af0408..00000000 --- a/apps/hub/src/artifacts/design-tools.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { type } from "arktype"; -import { mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; -import { dirname, join, sep } from "node:path"; -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 { withDesignTokens } from "./design-tokens.ts"; -import { DESIGN_STYLES } from "./design-tweaks.ts"; -import { resolveWorkflowProjectScope } from "../workflows/project-scope.ts"; -import { workflowHttpJson } from "../workflows/http-json.ts"; -import { failure, HubArtifactReceipt } from "./tools.ts"; - -interface DesignToolEnv extends BaseEnv { - toolCwd: string; - capabilities: RuntimeCapabilities; -} - -export const MAX_DESIGN_BYTES = 256 * 1024; - -// Writes a bundle's files under root, skipping any path that would land outside it. -export async function writeBundle( - root: string, - files: Readonly>, -) { - const written: Record = {}; - for (const [path, data] of Object.entries(files)) { - const target = join(root, path); - if (!target.startsWith(root + sep)) continue; - await mkdir(dirname(target), { recursive: true }); - await writeFile(target, data); - written[path] = data; - } - return written; -} - -// The coder builds against the design version its task names. It lands in .builder, which the -// coder's sandbox keeps read-only, so only the designer changes it. -export async function unpackDesign(content: string, workspace: string) { - const { files } = DesignBundle.assert(JSON.parse(content)); - const root = join(workspace, ".builder"); - await rm(root, { recursive: true, force: true }); - return writeBundle(root, files); -} - -async function readDesignFolder(workspace: string) { - const files: Record = {}; - for await (const path of new Bun.Glob("**/*").scan({ - cwd: join(workspace, ".builder"), - })) - files[path] = await readFile(join(workspace, ".builder", path), "utf8"); - return files; -} - -// Files the designer's .builder folder as its design concept's option. The person decides on the -// design once, by choosing among the filed options, never while a designer is still working. -export function createDesignTools() { - const definitions = [ - { - name: "file_design_option", - description: - "File the .builder folder (DESIGN.md and designs/) as the next version of your design concept's option. The person compares it with the other concepts' options and picks the one to build. The hub assigns its id, version and sha256 and returns them.", - inputSchema: { - type: "object", - properties: {}, - additionalProperties: false, - }, - }, - ]; - return defineTool({ - id: "@corbits-builder/artifacts/design-tools", - 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, - }; - try { - const scope = await resolveWorkflowProjectScope( - env.capabilities, - "designer", - signal, - ); - 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) - return { - callId: call.id, - content: `invalid_design: .builder needs DESIGN.md and ${DESIGN_STYLES}`, - isError: true, - }; - try { - files["DESIGN.md"] = withDesignTokens(doc, styles); - } catch (error) { - return { - callId: call.id, - content: `invalid_design: ${error instanceof Error ? error.message : String(error)}`, - isError: true, - }; - } - 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); - } - }, - }), - }); -} diff --git a/apps/hub/src/artifacts/service.ts b/apps/hub/src/artifacts/service.ts index 2d1ccecc..143be1b5 100644 --- a/apps/hub/src/artifacts/service.ts +++ b/apps/hub/src/artifacts/service.ts @@ -117,8 +117,11 @@ 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 { + DESIGN_STYLES, + MAX_DESIGN_BYTES, + setThemeTokens, +} from "@corbits-builder/tools-design"; import { coordinatorTweaksNote, designerTweaksNote, diff --git a/apps/hub/src/artifacts/tools.ts b/apps/hub/src/artifacts/tools.ts index 11579584..2ab17b6c 100644 --- a/apps/hub/src/artifacts/tools.ts +++ b/apps/hub/src/artifacts/tools.ts @@ -14,12 +14,13 @@ import { ArtifactReceipt, } from "@corbits-builder/schemas/artifact"; import type { WorkflowRole } from "@corbits-builder/schemas/installed-workflows"; -import { unpackDesign } from "./design-tools.ts"; +import { unpackDesign } from "./design-bundle.ts"; +import { projectScopeFailure } from "../workflows/project-scope.ts"; import { - projectScopeFailure, resolveWorkflowProjectScope, -} from "../workflows/project-scope.ts"; -import { workflowHttpJson, WorkflowHttpError } from "../workflows/http-json.ts"; + workflowHttpJson, + WorkflowHttpError, +} from "@corbits-builder/tools-hub"; interface ArtifactToolEnv extends BaseEnv { capabilities: RuntimeCapabilities; diff --git a/apps/hub/src/builds/app-repository.ts b/apps/hub/src/builds/app-repository.ts index d6145afc..096c1705 100644 --- a/apps/hub/src/builds/app-repository.ts +++ b/apps/hub/src/builds/app-repository.ts @@ -32,7 +32,7 @@ import { mediatedHttpJson, WorkflowHttpError, workflowHttpJson, -} from "../workflows/http-json.ts"; +} from "@corbits-builder/tools-hub"; const AppPushAccess = type({ path: "string", diff --git a/apps/hub/src/builds/app-snapshot.ts b/apps/hub/src/builds/app-snapshot.ts index 6398fb0e..3c34924c 100644 --- a/apps/hub/src/builds/app-snapshot.ts +++ b/apps/hub/src/builds/app-snapshot.ts @@ -12,7 +12,10 @@ import { DesignDraft, MAX_APP_BYTES, } from "@corbits-builder/schemas/artifact"; -import { WorkflowHttpError, workflowHttpJson } from "../workflows/http-json.ts"; +import { + WorkflowHttpError, + workflowHttpJson, +} from "@corbits-builder/tools-hub"; import { INPUTS_DIRECTORY } from "../config/inputs.ts"; // Installs and builds are rebuilt by setup and check-app, and .builder and the inputs come from diff --git a/apps/hub/src/builds/sidecar-tools.ts b/apps/hub/src/builds/sidecar-tools.ts index fef7123c..f83e1f75 100644 --- a/apps/hub/src/builds/sidecar-tools.ts +++ b/apps/hub/src/builds/sidecar-tools.ts @@ -11,8 +11,10 @@ import { type ToolRunner, } from "@intx/types/runtime"; import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; -import { resolveWorkflowProjectScope } from "../workflows/project-scope.ts"; -import { WorkflowHttpError } from "../workflows/http-json.ts"; +import { + resolveWorkflowProjectScope, + WorkflowHttpError, +} from "@corbits-builder/tools-hub"; import { APP_STATUS_TOOL, createCodingTools } from "./coding-tools.ts"; import { createPosixBuildExecutor } from "./posix-sandbox.ts"; import { browserTools } from "./browser.ts"; @@ -55,7 +57,7 @@ import { DESIGNS, touchesDesigns, type DesignCheck, -} from "./design-check.ts"; +} from "@corbits-builder/tools-design"; // A model retries a refused absolute path verbatim until the doom guard ends the run, as with the // workspace path with "/" typed for "-", or "/builder" meaning ".builder". Naming the workspace path diff --git a/apps/hub/src/builds/starter.ts b/apps/hub/src/builds/starter.ts index 74b52f31..1ad2dc7b 100644 --- a/apps/hub/src/builds/starter.ts +++ b/apps/hub/src/builds/starter.ts @@ -13,7 +13,7 @@ import { AppBundle, type ArtifactContent, } from "@corbits-builder/schemas/artifact"; -import { unpackDesign, writeBundle } from "../artifacts/design-tools.ts"; +import { unpackDesign, writeBundle } from "../artifacts/design-bundle.ts"; import { bundleFiles } from "./app-snapshot.ts"; import type { AppRepository } from "./app-repository.ts"; import { diff --git a/apps/hub/src/live-app/host.ts b/apps/hub/src/live-app/host.ts index 3626a00f..088970c2 100644 --- a/apps/hub/src/live-app/host.ts +++ b/apps/hub/src/live-app/host.ts @@ -14,7 +14,7 @@ import { liveAppWording } from "../config/live-app.ts"; import { errorCode } from "../request-error.ts"; import type { HostReport } from "./host-report.ts"; import { parseJson } from "../lib/json.ts"; -import { writeBundle } from "../artifacts/design-tools.ts"; +import { writeBundle } from "../artifacts/design-bundle.ts"; import { bundleFiles, collectApp } from "../builds/app-snapshot.ts"; import { buildPackageLayout } from "../builds/package-layout.ts"; import { diff --git a/apps/hub/src/projects/deploy-tools.ts b/apps/hub/src/projects/deploy-tools.ts index acd66ef8..5bfb12bf 100644 --- a/apps/hub/src/projects/deploy-tools.ts +++ b/apps/hub/src/projects/deploy-tools.ts @@ -1,7 +1,9 @@ +import { projectScopeFailure } from "../workflows/project-scope.ts"; import { - projectScopeFailure, resolveWorkflowProjectScope, -} from "../workflows/project-scope.ts"; + workflowHttpJson, + WorkflowHttpError, +} from "@corbits-builder/tools-hub"; import { defineTool, type BaseEnv, @@ -50,7 +52,6 @@ import { designLookIdeas, } from "../config/design.ts"; import { QUESTIONS_ASKED } from "../config/questions.ts"; -import { workflowHttpJson, WorkflowHttpError } from "../workflows/http-json.ts"; import { assignmentToWriterMessage } from "../workflows/writer-task.ts"; import { coderTaskMessage } from "@corbits-builder/coder/contract"; diff --git a/apps/hub/src/workflows/compaction.ts b/apps/hub/src/workflows/compaction.ts index b0cbbb77..8bf3397f 100644 --- a/apps/hub/src/workflows/compaction.ts +++ b/apps/hub/src/workflows/compaction.ts @@ -11,7 +11,7 @@ import type { RetryPolicy, } from "@intx/types/runtime"; import { REPETITION_ABORT } from "../inference/repetition.ts"; -import { CHECK_DESIGN_TOOL } from "../builds/design-check.ts"; +import { CHECK_DESIGN_TOOL } from "@corbits-builder/tools-design"; const COMPACTOR = "@corbits-builder/trim-history"; // Old tool arguments and results longer than this are replaced by a note saying what they were. Never diff --git a/apps/hub/src/workflows/entries/designer.ts b/apps/hub/src/workflows/entries/designer.ts index 65546716..600f387c 100644 --- a/apps/hub/src/workflows/entries/designer.ts +++ b/apps/hub/src/workflows/entries/designer.ts @@ -5,7 +5,8 @@ import { createSidecarCodingTools, type CodingSidecarEnv, } from "../../builds/sidecar-tools.ts"; -import { createDesignTools } from "../../artifacts/design-tools.ts"; +import { createDesignTools } from "@corbits-builder/tools-design/sidecar-bundle"; +import { failure } from "../../artifacts/tools.ts"; import { roleInference } from "../role-inference.ts"; import { compactingDirector, @@ -27,7 +28,7 @@ const agent = createDesignerAgent({ standards: [], inference: { ...source, parameters }, tools: [ - createDesignTools(), + createDesignTools({ failure }), mail, createSidecarCodingTools({ approval: "allow", workspace: "design" }), ], diff --git a/apps/hub/src/workflows/project-scope.ts b/apps/hub/src/workflows/project-scope.ts index 7ed53d54..121c38b1 100644 --- a/apps/hub/src/workflows/project-scope.ts +++ b/apps/hub/src/workflows/project-scope.ts @@ -1,47 +1,13 @@ -import { HUB_CREDENTIAL_HANDLE } from "@intx/types/credential-delivery"; -import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; -import { - WorkflowProjectScope, - type WorkflowRole, -} from "@corbits-builder/schemas/installed-workflows"; import { getLogger } from "@intx/log"; +import { projectScopeCode } from "@corbits-builder/tools-hub"; import { requestErrorMetadata } from "../request-error.ts"; -import { WorkflowHttpError, workflowHttpJson } from "./http-json.ts"; const log = getLogger(["corbits-builder", "hub", "workflow-project-scope"]); -const SAFE_CODE = /^[a-z0-9_:]+$/; -// A deployed agent keeps the bundle it started with; a field a newer hub adds must not refuse its scope. -const HubProjectScope = WorkflowProjectScope.onDeepUndeclaredKey("ignore"); -// The model sees only the original code (never a refusal body or arbitrary message); the cause stays in the hub log. export function projectScopeFailure(callId: string, error: unknown) { log.error( "Workflow project scope or tool call failed", requestErrorMetadata(error), ); - const code = - error instanceof WorkflowHttpError && error.refusalCode - ? error.refusalCode - : error instanceof Error && SAFE_CODE.test(error.message) - ? error.message - : "workflow_project_scope_unavailable"; - return { callId, content: code, isError: true }; -} - -export async function resolveWorkflowProjectScope( - capabilities: RuntimeCapabilities, - role: WorkflowRole, - signal: AbortSignal, -) { - const scope = HubProjectScope.assert( - await workflowHttpJson({ - capabilities, - handle: HUB_CREDENTIAL_HANDLE, - path: "/api/workflow-project-scope", - maxResponseBytes: 16384, - signal, - }), - ); - if (scope.role !== role) throw new Error("workflow_project_role_refused"); - return scope; + return { callId, content: projectScopeCode(error), isError: true }; } diff --git a/bun.lock b/bun.lock index 09de2913..fbf3fd5a 100644 --- a/bun.lock +++ b/bun.lock @@ -67,6 +67,8 @@ "@corbits-builder/designer": "workspace:*", "@corbits-builder/reviewer": "workspace:*", "@corbits-builder/schemas": "workspace:*", + "@corbits-builder/tools-design": "workspace:*", + "@corbits-builder/tools-hub": "workspace:*", "@corbits-builder/writer": "workspace:*", "@corbits/agent-token": "0.2.2", "@corbits/artifacts": "0.3.0", @@ -158,6 +160,25 @@ "arktype": "catalog:", }, }, + "packages/tools-design": { + "name": "@corbits-builder/tools-design", + "version": "0.1.0-rc.2", + "dependencies": { + "@corbits-builder/schemas": "workspace:*", + "@corbits-builder/tools-hub": "workspace:*", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", + "arktype": "catalog:", + }, + }, + "packages/tools-hub": { + "name": "@corbits-builder/tools-hub", + "version": "0.1.0-rc.2", + "dependencies": { + "@corbits-builder/schemas": "workspace:*", + "@intx/types": "workspace:*", + }, + }, "vendor/interchange/apps/sidecar": { "name": "@intx/sidecar-app", "version": "0.5.0", @@ -732,6 +753,10 @@ "@corbits-builder/schemas": ["@corbits-builder/schemas@workspace:packages/schemas"], + "@corbits-builder/tools-design": ["@corbits-builder/tools-design@workspace:packages/tools-design"], + + "@corbits-builder/tools-hub": ["@corbits-builder/tools-hub@workspace:packages/tools-hub"], + "@corbits-builder/web": ["@corbits-builder/web@workspace:apps/web"], "@corbits-builder/writer": ["@corbits-builder/writer@workspace:workflows/writer"], diff --git a/docs/DEV.md b/docs/DEV.md index ad399fc0..e4e42821 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -7,16 +7,18 @@ running with Docker Compose is in ## Architecture in brief -| Path | What it is | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `vendor/interchange` | [Interchange](https://github.com/faremeter/interchange) at the commit in `vendor/VENDORED_REVISION`, plus local patches recorded in `vendor/interchange.manifest.json`. It owns the agent loop, mail, spawning, approvals, credentials, sidecars and run lifecycle. | -| `apps/hub` | The server: a Postgres control plane, tenancy and sign-in, documents (versioned artifacts with content hashes), approvals and the glue that starts each role. It runs Interchange in-process. | -| `packages/client` | A typed client for the hub's API (`@corbits-builder/client`). It never touches the database or Interchange internals. | -| `apps/web` | The UI: Vite and React over that client, with its own small router in `src/routes.ts`. | -| `workflows/*` | One package per role: its agent, prompt and workflow definition; the hub supplies its tools. | -| `packages/schemas` | Types and schemas shared by the hub, the client and the workflows. | -| `scripts/` | Dev and verification scripts: pin and vendor checks, the commit check, the Google sign-in emulator and React UI API generation. | -| `deploy/` | The Dockerfile and Compose files ([SELF_HOST.md](https://github.com/corbitsdev/corbits-builder/blob/main/docs/SELF_HOST.md)). | +| Path | What it is | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `vendor/interchange` | [Interchange](https://github.com/faremeter/interchange) at the commit in `vendor/VENDORED_REVISION`, plus local patches recorded in `vendor/interchange.manifest.json`. It owns the agent loop, mail, spawning, approvals, credentials, sidecars and run lifecycle. | +| `apps/hub` | The server: a Postgres control plane, tenancy and sign-in, documents (versioned artifacts with content hashes), approvals and the glue that starts each role. It runs Interchange in-process. | +| `packages/client` | A typed client for the hub's API (`@corbits-builder/client`). It never touches the database or Interchange internals. | +| `apps/web` | The UI: Vite and React over that client, with its own small router in `src/routes.ts`. | +| `workflows/*` | One package per role: its agent, prompt and workflow definition; the hub supplies its tools. | +| `packages/schemas` | Types and schemas shared by the hub, the client and the workflows. | +| `packages/tools-design` | The designer's tools: filing a design option and the design check (`@corbits-builder/tools-design`). | +| `packages/tools-hub` | The helpers the hub's agent tools use to call the hub and resolve a run's project (`@corbits-builder/tools-hub`). | +| `scripts/` | Dev and verification scripts: pin and vendor checks, the commit check, the Google sign-in emulator and React UI API generation. | +| `deploy/` | The Dockerfile and Compose files ([SELF_HOST.md](https://github.com/corbitsdev/corbits-builder/blob/main/docs/SELF_HOST.md)). | The roles: diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 6cb077d1..86a0e49c 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -76,7 +76,7 @@ run's own credential: `instruct_coder` ([apps/hub/src/projects/deploy-tools.ts](https://github.com/corbitsdev/corbits-builder/blob/main/apps/hub/src/projects/deploy-tools.ts)); - design filing: `file_design_option` - ([apps/hub/src/artifacts/design-tools.ts](https://github.com/corbitsdev/corbits-builder/blob/main/apps/hub/src/artifacts/design-tools.ts)); + ([packages/tools-design](https://github.com/corbitsdev/corbits-builder/tree/main/packages/tools-design)); - skills: `read_skill` ([apps/hub/src/skills/tools.ts](https://github.com/corbitsdev/corbits-builder/blob/main/apps/hub/src/skills/tools.ts)); - workspace tools for the designer and coder (files, shell, search, package @@ -111,22 +111,24 @@ Environment files, installs and build output never leave the workspace. ## Package layout -| Path | Package | What it holds | -| ------------------------- | ------------------------------ | ---------------------------------------------------------------- | -| `apps/hub` | `@corbits-builder/hub` | The server: routes, services, hub tools, provisioning, live app. | -| `packages/client` | `@corbits-builder/client` | The typed client for the hub's API. | -| `apps/web` | `@corbits-builder/web` | The interface, built only on the client and the schemas. | -| `workflows/coordinator` | `@corbits-builder/coordinator` | The coordinator's agent, prompt and workflow. | -| `workflows/writer` | `@corbits-builder/writer` | The document writer and its per-kind skills. | -| `workflows/reviewer` | `@corbits-builder/reviewer` | The document reviewer. | -| `workflows/designer` | `@corbits-builder/designer` | The designer, its starter, themes and skills. | -| `workflows/coder` | `@corbits-builder/coder` | The coder, its app starter and `check-app`. | -| `packages/schemas` | `@corbits-builder/schemas` | Types and schemas shared by the hub, client and workflows. | -| `vendor/interchange` | | Interchange at the pinned revision. | -| `deploy/` | | The Dockerfile and Compose files. | -| `scripts/` | | Dev and verification scripts. | -| `skills/` | | Agent skills to install Builder and drive it through its API. | -| `test/integration`, `e2e` | | Cross-module and full-stack tests. | +| Path | Package | What it holds | +| ------------------------- | ------------------------------- | ---------------------------------------------------------------- | +| `apps/hub` | `@corbits-builder/hub` | The server: routes, services, hub tools, provisioning, live app. | +| `packages/client` | `@corbits-builder/client` | The typed client for the hub's API. | +| `apps/web` | `@corbits-builder/web` | The interface, built only on the client and the schemas. | +| `workflows/coordinator` | `@corbits-builder/coordinator` | The coordinator's agent, prompt and workflow. | +| `workflows/writer` | `@corbits-builder/writer` | The document writer and its per-kind skills. | +| `workflows/reviewer` | `@corbits-builder/reviewer` | The document reviewer. | +| `workflows/designer` | `@corbits-builder/designer` | The designer, its starter, themes and skills. | +| `workflows/coder` | `@corbits-builder/coder` | The coder, its app starter and `check-app`. | +| `packages/schemas` | `@corbits-builder/schemas` | Types and schemas shared by the hub, client and workflows. | +| `packages/tools-design` | `@corbits-builder/tools-design` | The designer's design-option filing and design-check tools. | +| `packages/tools-hub` | `@corbits-builder/tools-hub` | The hub HTTP/JSON and project-scope helpers agent tools use. | +| `vendor/interchange` | | Interchange at the pinned revision. | +| `deploy/` | | The Dockerfile and Compose files. | +| `scripts/` | | Dev and verification scripts. | +| `skills/` | | Agent skills to install Builder and drive it through its API. | +| `test/integration`, `e2e` | | Cross-module and full-stack tests. | Workflow packages declare their agents and workflows with Interchange's definition exports (`@intx/agent/authoring`, `@intx/workflow/definition`). diff --git a/packages/tools-design/README.md b/packages/tools-design/README.md new file mode 100644 index 00000000..ea4d03c3 --- /dev/null +++ b/packages/tools-design/README.md @@ -0,0 +1,32 @@ +# Tools design + +`@corbits-builder/tools-design`: the designer's tools. `file_design_option` +files the designer's `.builder` folder as its design concept's option once +the design check (check-app on the mockup) passes, and `withDesignTokens` +writes the stylesheet's tokens into the filed `DESIGN.md`. + +## Where it fits + +The [hub][hub]'s designer workflow attaches `createDesignTools` from +`./sidecar-bundle` and passes the hub's `failure`, which logs a thrown error +and turns it into the code the model sees. The hub's coding tools register a +workspace's design check with `createDesignCheck`, and the filing tool waits +on it; both load into the one workflow bundle. It reaches the hub through +[`packages/tools-hub`][tools-hub]. + +## Layout + +- `src/index.ts`: the logic exports. +- `src/sidecar-bundle.ts`: `createDesignTools`, the `defineTool` bundle. +- `src/file-design-option.ts`: the filing logic and its definition. +- `src/design-check.ts`: the design check and `check_design`'s definition. +- `src/design-tokens.ts` and `src/design-tweaks.ts`: the stylesheet tokens. + +## Working on it + +The bundle id `@corbits-builder/artifacts/design-tools` names the tools' +grants; keep it. `bun run typecheck` checks every importer and `bun run test` +runs the tests. + +[hub]: https://github.com/corbitsdev/corbits-builder/tree/main/apps/hub +[tools-hub]: https://github.com/corbitsdev/corbits-builder/tree/main/packages/tools-hub diff --git a/packages/tools-design/package.json b/packages/tools-design/package.json new file mode 100644 index 00000000..88bfa0f3 --- /dev/null +++ b/packages/tools-design/package.json @@ -0,0 +1,19 @@ +{ + "name": "@corbits-builder/tools-design", + "version": "0.1.0-rc.2", + "private": true, + "license": "SEE LICENSE IN ../../LICENSE.md", + "author": "Sawyer Cutler ", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./sidecar-bundle": "./src/sidecar-bundle.ts" + }, + "dependencies": { + "@corbits-builder/schemas": "workspace:*", + "@corbits-builder/tools-hub": "workspace:*", + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", + "arktype": "catalog:" + } +} diff --git a/apps/hub/src/builds/design-check.ts b/packages/tools-design/src/design-check.ts similarity index 99% rename from apps/hub/src/builds/design-check.ts rename to packages/tools-design/src/design-check.ts index 1cc2f8a2..25604d9c 100644 --- a/apps/hub/src/builds/design-check.ts +++ b/packages/tools-design/src/design-check.ts @@ -14,7 +14,7 @@ export type DesignCheck = ReturnType; // By workspace. The designer's coding tools run the check and file_design_option waits on it; both // tool packages load into the one workflow child of the designer's bundle -// (unified-execution-host-design.md, "The tool-execution locus"; build-packages.ts bundles a role). +// (unified-execution-host-design.md, "The tool-execution locus"; the hub's build-packages.ts bundles a role). const checks = new Map(); export const designCheckFor = (workspace: string) => checks.get(workspace); diff --git a/apps/hub/src/artifacts/design-tokens.test.ts b/packages/tools-design/src/design-tokens.test.ts similarity index 91% rename from apps/hub/src/artifacts/design-tokens.test.ts rename to packages/tools-design/src/design-tokens.test.ts index cbf8347a..0537154e 100644 --- a/apps/hub/src/artifacts/design-tokens.test.ts +++ b/packages/tools-design/src/design-tokens.test.ts @@ -4,7 +4,7 @@ import { withDesignTokens } from "./design-tokens.ts"; test("filed tokens come from styles.css and replace the front matter already there", async () => { const css = await Bun.file( new URL( - "../../../../workflows/designer/themes/retro/designs/styles.css", + "../../../workflows/designer/themes/retro/designs/styles.css", import.meta.url, ), ).text(); diff --git a/apps/hub/src/artifacts/design-tokens.ts b/packages/tools-design/src/design-tokens.ts similarity index 100% rename from apps/hub/src/artifacts/design-tokens.ts rename to packages/tools-design/src/design-tokens.ts diff --git a/apps/hub/src/artifacts/design-tweaks.test.ts b/packages/tools-design/src/design-tweaks.test.ts similarity index 90% rename from apps/hub/src/artifacts/design-tweaks.test.ts rename to packages/tools-design/src/design-tweaks.test.ts index 0b98ff08..f9c9cd00 100644 --- a/apps/hub/src/artifacts/design-tweaks.test.ts +++ b/packages/tools-design/src/design-tweaks.test.ts @@ -4,7 +4,7 @@ import { setThemeTokens } from "./design-tweaks.ts"; test("a saved primary reaches every dark rule of a shipped theme", async () => { const css = await Bun.file( new URL( - "../../../../workflows/designer/themes/modern-sleek/designs/styles.css", + "../../../workflows/designer/themes/modern-sleek/designs/styles.css", import.meta.url, ), ).text(); diff --git a/apps/hub/src/artifacts/design-tweaks.ts b/packages/tools-design/src/design-tweaks.ts similarity index 100% rename from apps/hub/src/artifacts/design-tweaks.ts rename to packages/tools-design/src/design-tweaks.ts diff --git a/packages/tools-design/src/file-design-option.ts b/packages/tools-design/src/file-design-option.ts new file mode 100644 index 00000000..6684644a --- /dev/null +++ b/packages/tools-design/src/file-design-option.ts @@ -0,0 +1,105 @@ +import { type } from "arktype"; +import { readFile, realpath } from "node:fs/promises"; +import { join } from "node:path"; +import type { ToolResult } from "@intx/types/runtime"; +import { + ArtifactReceipt, + DesignBundle, +} from "@corbits-builder/schemas/artifact"; +import { designCheckFor } from "./design-check.ts"; +import { withDesignTokens } from "./design-tokens.ts"; +import { DESIGN_STYLES } from "./design-tweaks.ts"; + +export const MAX_DESIGN_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 filing of an older agent. +const HubArtifactReceipt = ArtifactReceipt.onDeepUndeclaredKey("ignore"); + +export const fileDesignOptionDefinition = { + name: "file_design_option", + description: + "File the .builder folder (DESIGN.md and designs/) as the next version of your design concept's option. The person compares it with the other concepts' options and picks the one to build. The hub assigns its id, version and sha256 and returns them.", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, +}; + +async function readDesignFolder(workspace: string) { + const files: Record = {}; + for await (const path of new Bun.Glob("**/*").scan({ + cwd: join(workspace, ".builder"), + })) + files[path] = await readFile(join(workspace, ".builder", path), "utf8"); + return files; +} + +// Files the workspace's .builder folder through postArtifact, the hub's artifacts route for the +// run's project. +export async function fileDesignOption(input: { + callId: string; + workspace: string; + postArtifact: (body: unknown) => Promise; + signal: AbortSignal; +}): Promise { + const { callId, workspace, postArtifact, signal } = input; + // The person reviews only a mockup that passed check-app as it is filed. + const check = designCheckFor(await realpath(workspace)); + if (!check) + return { + callId, + 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, + content: `design_check_failed: the design is filed once it passes check-app.\n${checked.checklist}`, + isError: true, + }; + const files = await readDesignFolder(workspace); + const doc = files["DESIGN.md"]; + const styles = files[DESIGN_STYLES]; + if (doc === undefined || styles === undefined) + return { + callId, + content: `invalid_design: .builder needs DESIGN.md and ${DESIGN_STYLES}`, + isError: true, + }; + try { + files["DESIGN.md"] = withDesignTokens(doc, styles); + } catch (error) { + return { + callId, + content: `invalid_design: ${error instanceof Error ? error.message : String(error)}`, + isError: true, + }; + } + const bundle = DesignBundle({ files }); + if (bundle instanceof type.errors) + return { + callId, + content: "invalid_design: " + bundle.summary, + isError: true, + }; + const content = JSON.stringify(bundle); + const bytes = Buffer.byteLength(content); + if (bytes > MAX_DESIGN_BYTES) + return { + callId, + 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 postArtifact({ + kind: "design_option", + title: title.slice(0, 512), + content, + }), + ); + return { callId, content: JSON.stringify(artifact) }; +} diff --git a/packages/tools-design/src/index.ts b/packages/tools-design/src/index.ts new file mode 100644 index 00000000..5e6ac09d --- /dev/null +++ b/packages/tools-design/src/index.ts @@ -0,0 +1,15 @@ +export { + CHECK_DESIGN_TOOL, + checkDesignDefinition, + createDesignCheck, + type DesignCheck, + designCheckFor, + DESIGNS, + touchesDesigns, +} from "./design-check.ts"; +export { DESIGN_STYLES, setThemeTokens } from "./design-tweaks.ts"; +export { + fileDesignOption, + fileDesignOptionDefinition, + MAX_DESIGN_BYTES, +} from "./file-design-option.ts"; diff --git a/packages/tools-design/src/sidecar-bundle.ts b/packages/tools-design/src/sidecar-bundle.ts new file mode 100644 index 00000000..39759bbc --- /dev/null +++ b/packages/tools-design/src/sidecar-bundle.ts @@ -0,0 +1,71 @@ +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 type { ToolResult } from "@intx/types/runtime"; +import { + resolveWorkflowProjectScope, + workflowHttpJson, +} from "@corbits-builder/tools-hub"; +import { + fileDesignOption, + fileDesignOptionDefinition, +} from "./file-design-option.ts"; + +export interface DesignToolEnv extends BaseEnv { + toolCwd: string; + capabilities: RuntimeCapabilities; +} + +// Files the designer's .builder folder as its design concept's option. The person decides on the +// design once, by choosing among the filed options, never while a designer is still working. +// failure turns a thrown error into the tool result, so the host keeps its own logging and codes. +export function createDesignTools(options: { + failure: (callId: string, error: unknown) => ToolResult; +}) { + return defineTool({ + id: "@corbits-builder/artifacts/design-tools", + credentials: [{ handle: HUB_CREDENTIAL_HANDLE }], + requires: ["toolCwd", "capabilities"], + definitions: [{ name: fileDesignOptionDefinition.name }], + factory: (env) => ({ + definitions: [fileDesignOptionDefinition], + run: async (call, signal) => { + if (call.name !== fileDesignOptionDefinition.name) + return { + callId: call.id, + content: "unknown_design_tool", + isError: true, + }; + try { + const scope = await resolveWorkflowProjectScope( + env.capabilities, + "designer", + signal, + ); + const path = + "/api/tenants/" + + encodeURIComponent(scope.tenantId) + + "/projects/" + + encodeURIComponent(scope.projectId) + + "/artifacts"; + return await fileDesignOption({ + callId: call.id, + workspace: env.toolCwd, + postArtifact: (body) => + workflowHttpJson({ + capabilities: env.capabilities, + handle: HUB_CREDENTIAL_HANDLE, + path, + body, + maxResponseBytes: 16384, + signal, + }), + signal, + }); + } catch (error) { + return options.failure(call.id, error); + } + }, + }), + }); +} diff --git a/packages/tools-hub/README.md b/packages/tools-hub/README.md new file mode 100644 index 00000000..698332e3 --- /dev/null +++ b/packages/tools-hub/README.md @@ -0,0 +1,30 @@ +# Tools hub + +`@corbits-builder/tools-hub`: the helpers agent tools use to reach the +hub from inside a run. `workflowHttpJson` calls a hub route through the run's +mediated hub credential and throws `WorkflowHttpError` on a refusal, +`resolveWorkflowProjectScope` reads the project a run belongs to, and +`projectScopeCode` turns a failure into the only code the model sees. + +## Where it fits + +Pure functions with no tool definitions and no runtime of their own. It +imports only [`packages/schemas`][schemas] and Interchange's types. The +[hub][hub]'s agent tools use it, and the workflow bundles carry it with +those tools. + +## Layout + +- `src/index.ts`: the package's exports. +- `src/http-json.ts`: `workflowHttpJson`, `mediatedHttpJson` and + `WorkflowHttpError`. +- `src/project-scope.ts`: `resolveWorkflowProjectScope` and + `projectScopeCode`. + +## Working on it + +Refusal bodies never reach the model; keep any new field to a public code. +`bun run typecheck` checks every importer and `bun run test` runs the tests. + +[hub]: https://github.com/corbitsdev/corbits-builder/tree/main/apps/hub +[schemas]: https://github.com/corbitsdev/corbits-builder/tree/main/packages/schemas diff --git a/packages/tools-hub/package.json b/packages/tools-hub/package.json new file mode 100644 index 00000000..25b12461 --- /dev/null +++ b/packages/tools-hub/package.json @@ -0,0 +1,15 @@ +{ + "name": "@corbits-builder/tools-hub", + "version": "0.1.0-rc.2", + "private": true, + "license": "SEE LICENSE IN ../../LICENSE.md", + "author": "Sawyer Cutler ", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@corbits-builder/schemas": "workspace:*", + "@intx/types": "workspace:*" + } +} diff --git a/apps/hub/src/workflows/http-json.ts b/packages/tools-hub/src/http-json.ts similarity index 100% rename from apps/hub/src/workflows/http-json.ts rename to packages/tools-hub/src/http-json.ts diff --git a/packages/tools-hub/src/index.ts b/packages/tools-hub/src/index.ts new file mode 100644 index 00000000..ce9147e6 --- /dev/null +++ b/packages/tools-hub/src/index.ts @@ -0,0 +1,9 @@ +export { + mediatedHttpJson, + WorkflowHttpError, + workflowHttpJson, +} from "./http-json.ts"; +export { + projectScopeCode, + resolveWorkflowProjectScope, +} from "./project-scope.ts"; diff --git a/packages/tools-hub/src/project-scope.test.ts b/packages/tools-hub/src/project-scope.test.ts new file mode 100644 index 00000000..47ed721b --- /dev/null +++ b/packages/tools-hub/src/project-scope.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test"; +import { WorkflowHttpError } from "./http-json.ts"; +import { projectScopeCode } from "./project-scope.ts"; + +test("project scope failures expose only a refusal code, a safe code or the fallback", () => { + expect( + projectScopeCode( + new WorkflowHttpError(403, "unspecified", "project_access_refused"), + ), + ).toBe("project_access_refused"); + expect(projectScopeCode(new Error("workflow_project_role_refused"))).toBe( + "workflow_project_role_refused", + ); + expect(projectScopeCode(new Error("ENOENT: /secret/path"))).toBe( + "workflow_project_scope_unavailable", + ); +}); diff --git a/packages/tools-hub/src/project-scope.ts b/packages/tools-hub/src/project-scope.ts new file mode 100644 index 00000000..c3a4c198 --- /dev/null +++ b/packages/tools-hub/src/project-scope.ts @@ -0,0 +1,39 @@ +import { HUB_CREDENTIAL_HANDLE } from "@intx/types/credential-delivery"; +import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; +import { + WorkflowProjectScope, + type WorkflowRole, +} from "@corbits-builder/schemas/installed-workflows"; +import { WorkflowHttpError, workflowHttpJson } from "./http-json.ts"; + +// A deployed agent keeps the bundle it started with; a field a newer hub adds must not refuse its scope. +const HubProjectScope = WorkflowProjectScope.onDeepUndeclaredKey("ignore"); + +export async function resolveWorkflowProjectScope( + capabilities: RuntimeCapabilities, + role: WorkflowRole, + signal: AbortSignal, +) { + const scope = HubProjectScope.assert( + await workflowHttpJson({ + capabilities, + handle: HUB_CREDENTIAL_HANDLE, + path: "/api/workflow-project-scope", + maxResponseBytes: 16384, + signal, + }), + ); + if (scope.role !== role) throw new Error("workflow_project_role_refused"); + return scope; +} + +const SAFE_CODE = /^[a-z0-9_:]+$/; + +// The model sees only the original code (never a refusal body or arbitrary message); the cause stays in the hub log. +export function projectScopeCode(error: unknown): string { + if (error instanceof WorkflowHttpError && error.refusalCode) + return error.refusalCode; + if (error instanceof Error && SAFE_CODE.test(error.message)) + return error.message; + return "workflow_project_scope_unavailable"; +}