Skip to content
Closed
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
23 changes: 9 additions & 14 deletions apps/hub/src/artifacts/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ export function mountArtifactRoutes(
tags: ["Artifacts"],
operationId: "chooseDesign",
description:
"The person chooses a filed design_option at the version they saw. The hub files it as the project's design, records the person's approval of exactly that design version and hash, stops the other concepts' designers, and tells the coordinator which design the person chose. Only a person may choose; the coordinator never does.",
"Called by the coordinator's choose_design tool once the person approved its native ask. The approval covers the concept, so the hub files the concept's newest design_option version, look tweaks the person saved while deciding included, as the project's design, records that approval against exactly that option and design version and hash in the same transaction, and stops the other concepts' designers. Each approval chooses once.",
security,
requestBody: {
required: true,
Expand All @@ -279,27 +279,22 @@ export function mountArtifactRoutes(
"application/json": { schema: resolver(DesignChoiceReceipt) },
},
},
403: {
description: "The caller is not a person who manages the project",
},
404: { description: "No filed design option with that id" },
403: { description: "The caller does not manage the project" },
404: { description: "No filed design option for that concept" },
409: {
description: "The option has a newer version than the one chosen",
description:
"No approval of choose_design for that concept (approval_not_resolved), or it already chose a design (approval_already_used)",
},
},
}),
async (c) => {
const current = actorOf(c);
return c.json(
async (c) =>
c.json(
await service.chooseDesign(
current,
actorOf(c),
c.req.param("projectId"),
await body(c),
(runId, content) =>
mailRunAsCaller(c, nativeFetch, current.tenantId, runId, content),
),
);
},
),
);
app.get(
"/:projectId/materials/accepted",
Expand Down
148 changes: 86 additions & 62 deletions apps/hub/src/artifacts/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,6 @@ import {
coordinatorTweaksNote,
designerTweaksNote,
} from "../config/design-tweaks.ts";
import { designChosenNote } from "../config/design.ts";
import { loadSettings } from "../settings/policy.ts";
import {
agentDecidesApprovals,
Expand Down Expand Up @@ -169,9 +168,8 @@ import { requestErrorMetadata } from "../request-error.ts";
const log = getLogger(["corbits-builder", "hub", "artifacts"]);

// The native asks are the human approvals. A request_approval approval covers the documents of
// the kinds it asked about and an instruct_coder approval every filed document. The person approves
// the design by choosing a design option. The app is the coder's own output, filed while it works,
// so no approval covers it.
// the kinds it asked about and an instruct_coder approval every filed document. The app is the
// coder's own output, filed while it works, so no approval covers it.
const APPROVAL_COVERS: Record<string, (kind: string) => boolean> = {
request_approval: (kind) =>
kind !== "app" && kind !== "design" && kind !== "design_option",
Expand Down Expand Up @@ -2205,74 +2203,106 @@ export function createArtifactService(
});
},

// The person's own choice among the filed design options, never the coordinator's: the option
// at the version they saw is filed as the design, and they approve exactly that design version.
// Called by choose_design after the person approved its native ask. A choose_design approval
// covers the concept the person chose, so the option's newest version is filed as the design,
// look tweaks they saved while deciding included, and the approval covers the option and the
// design filed from it. An approval is used once, so a standing grant never chooses a design
// without the person. The approval, the design and both records commit together or not at all.
async chooseDesign(
actor: Actor,
projectId: string,
raw: unknown,
mailRun: (runId: string, content: string) => Promise<void>,
): Promise<DesignChoiceReceipt> {
const input = DesignChoice(raw);
if (input instanceof type.errors)
throw new ProjectError(400, "invalid_design_choice", {
issues: input,
});
await requireProject(actor, projectId, "manage");
const [chooser] = await rawDB
.select({ kind: principal.kind })
.from(principal)
.where(eq(principal.id, actor.principalId));
if (chooser?.kind !== "user")
throw new ProjectError(403, "design_choice_person_only");
const option = await getArtifact(db, input.artifactId);
if (
!option ||
option.tenantId !== actor.tenantId ||
option.kind !== "design_option"
)
throw new ProjectError(404, "design_option_not_found", {
artifactId: input.artifactId,
});
const [newest] = (await listArtifactVersions(db, option.id, { limit: 1 }))
.versions;
const stored = await getArtifactVersion(db, option.id, input.version);
if (!stored || newest?.version !== input.version)
throw new ProjectError(409, "artifact_ref_stale", {
artifactId: input.artifactId,
version: input.version,
});
const [existing] = (
await listArtifacts(db, actor.tenantId, { kind: "design", limit: 1 })
).rows;
const written = existing
? await writeArtifactVersion(db, {
scope: actor,
artifactId: existing.id,
title: stored.title,
content: stored.content,
const { design, title } = await db.transaction(async (tx) => {
const [granted] = await tx
.select({ id: approval.id })
.from(approval)
.where(
and(
eq(approval.tenantId, actor.tenantId),
eq(approval.status, "approved"),
eq(sql`${approval.toolDefinition}->>'name'`, "choose_design"),
eq(sql`${approval.toolArguments}->>'concept'`, input.concept),
),
)
.orderBy(desc(approval.resolvedAt))
.limit(1)
.for("update");
if (!granted)
throw new ProjectError(409, "approval_not_resolved", {
tool: "choose_design",
});
const [recorded] = await tx
.select({ approvalId: artifactApproval.approvalId })
.from(artifactApproval)
.where(eq(artifactApproval.approvalId, granted.id))
.limit(1);
if (recorded)
throw new ProjectError(409, "approval_already_used", {
tool: "choose_design",
});
const option = (
await listArtifacts(tx, actor.tenantId, {
kind: "design_option",
limit: MAX_LIST_LIMIT,
})
: await db.transaction((tx) =>
createArtifact(tx, {
).rows.find((row) => row.title === input.concept);
const stored = option
? await getArtifactVersion(tx, option.id, option.version)
: undefined;
if (!option || !stored?.contentSha256)
throw new ProjectError(404, "design_option_not_found", {
concept: input.concept,
});
const [existing] = (
await listArtifacts(tx, actor.tenantId, {
kind: "design",
limit: 1,
})
).rows;
const written = existing
? await writeArtifactVersion(tx, {
scope: actor,
artifactId: existing.id,
title: stored.title,
content: stored.content,
})
: await createArtifact(tx, {
scope: actor,
ownerPrincipalId: actor.principalId,
kind: "design",
title: stored.title,
content: stored.content,
source: { origin: "manual" },
}),
);
const design: ArtifactRef = {
artifactId: "artifactId" in written ? written.artifactId : written.id,
kind: "design",
version: written.version,
sha256: written.contentSha256!,
};
await rawDB.insert(artifactImportApproval).values({
...design,
tenantId: actor.tenantId,
projectId,
principalId: actor.principalId,
});
const design: ArtifactRef = {
artifactId: "artifactId" in written ? written.artifactId : written.id,
kind: "design",
version: written.version,
sha256: written.contentSha256!,
};
const optionRef: ArtifactRef = {
artifactId: option.id,
kind: "design_option",
version: stored.version,
sha256: stored.contentSha256,
};
await tx.insert(artifactApproval).values(
[optionRef, design].map((ref) => ({
approvalId: granted.id,
tenantId: actor.tenantId,
projectId,
tool: "choose_design",
...ref,
})),
);
return { design, title: stored.title };
});
// The other concepts' designers would file options nobody compares and hold their slots.
const others = await rawDB
Expand All @@ -2283,7 +2313,7 @@ export function createArtifactService(
and(
eq(designConcept.tenantId, actor.tenantId),
eq(designConcept.projectId, projectId),
ne(designConcept.label, stored.title),
ne(designConcept.label, title),
inArray(workflowRun.status, ["deployed", "running"]),
),
);
Expand All @@ -2292,13 +2322,7 @@ export function createArtifactService(
cancelRun?.(actor.tenantId, runId, "design_concept_not_picked"),
),
);
const coordinator = await activeCoordinator(projectId);
if (coordinator)
await mailRun(
coordinator.runId,
designChosenNote(stored.title, design.version),
);
return { design, concept: stored.title };
return { design, concept: title };
},

approvalRounds: createApprovalRoundService(
Expand Down
2 changes: 1 addition & 1 deletion apps/hub/src/auth/agent-token-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ export function createAgentTokenBridge(
((c.req.method === "GET" &&
/^(|\/audit|\/artifacts(?:\/[^/]+)?)$/.test(suffix)) ||
(c.req.method === "POST" &&
/^(\/mutations|\/artifacts|\/artifact-approvals|\/design-concepts|\/presenter-assignments)$/.test(
/^(\/mutations|\/artifacts|\/artifact-approvals|\/design-choice|\/design-concepts|\/presenter-assignments)$/.test(
suffix,
)));
const deployRoute =
Expand Down
4 changes: 0 additions & 4 deletions apps/hub/src/config/design.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,3 @@ export const designConceptAssignment = (concept: {
// The person's own ideas for the look, carried to each designer in their words.
export const designLookIdeas = (ideas: string) =>
`The person's ideas for the look, in their words: ${ideas.trim()}\n\n`;

// The hub's note to the coordinator once the person chose a design option in the conversation.
export const designChosenNote = (concept: string, version: number) =>
`The person chose the ${concept} design concept. It is filed as the design at version ${String(version)}, approved by them. The build plan is written from it next.`;
1 change: 1 addition & 0 deletions apps/hub/src/config/project-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const list = (items: readonly string[]) =>
const APPROVAL: Record<string, string> = {
instruct_coder: "Approve the change for the app builder",
design_app: "Approve the design work",
choose_design: "Choose the design to build from",
install_packages: "Approve the packages the app builder asked for",
};

Expand Down
5 changes: 3 additions & 2 deletions apps/hub/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,9 @@ export const artifactApproval = pgTable(
],
);

// Importing a project file and choosing a design option are the person's own
// decisions, so no native approval exists to reference; the row names that person instead.
// Importing a project file is the person's own decision, so no native approval
// exists to reference; the row names that person instead. The seed script imports
// its fixture projects the same way, their chosen design included.
export const artifactImportApproval = pgTable(
"sb_artifact_import_approval",
{
Expand Down
29 changes: 29 additions & 0 deletions apps/hub/src/projects/deploy-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,3 +394,32 @@ test("the build plan starts only from the design the person chose", async () =>
).toBeUndefined();
expect(deployed).toHaveLength(1);
});

test("a design choice the hub refuses tells the coordinator to ask the person again", async () => {
const fetch = async () =>
Response.json(
{ code: "approval_already_used", tool: "choose_design" },
{ status: 409 },
);
const capabilities = {
resolve: (name: string) =>
name === "credentials"
? { resolve: async () => ({ kind: "http", fetch }) }
: { send: async () => undefined },
} as unknown as RuntimeCapabilities;
const result = await createDeployTools({
tenantId: "tenant",
projectId: "project",
domain: "example.invalid",
workflows,
maxResultBytes: 16384,
})({ capabilities } as never).run(
{ id: "call", name: "choose_design", arguments: { concept: "Harbour" } },
new AbortController().signal,
);
expect(result.isError).toBe(true);
expect(result.content).toStartWith("approval_already_used");
expect(result.content).toEndWith(
"no design was filed, so ask the person again with choose_design",
);
});
25 changes: 23 additions & 2 deletions apps/hub/src/projects/deploy-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ const ReviewArtifact = type({
instruction: Instruction,
});
const DeployCoder = type({ "+": "reject", "instruction?": Instruction });
const ChooseDesign = type({ "+": "reject", concept: "string > 0" });
const InstructCoder = type({ "+": "reject", instruction: Instruction });
const DesignApp = type({
"+": "reject",
Expand Down Expand Up @@ -172,7 +173,7 @@ const notApproved = (kind: string, version: number) =>
" version " +
String(version) +
(kind === "design"
? "; they approve a design by confirming a design option in the conversation"
? "; ask them to choose a design option with choose_design"
: "; ask them with request_approval"),
);

Expand Down Expand Up @@ -279,7 +280,7 @@ export function createDeployTools(options?: {
{
name: "deploy_coder",
description:
"Have the coder build the whole app from the filed problem brief, constraints, approaches, product requirements, build plan and design. The documents are the assignment; an optional instruction adds a note to it. It starts once a person approved the current version of each document: the design by confirming a design option in the conversation, the others through request_approval.",
"Have the coder build the whole app from the filed problem brief, constraints, approaches, product requirements, build plan and design. The documents are the assignment; an optional instruction adds a note to it. It starts once a person approved the current version of each document: the design through choose_design, the others through request_approval.",
inputSchema: { ...DeployCoder.toJsonSchema() },
},
{
Expand All @@ -299,6 +300,12 @@ export function createDeployTools(options?: {
).join("\n"),
inputSchema: { ...DesignApp.toJsonSchema() },
},
{
name: "choose_design",
description:
"Ask the person to choose the filed design option of one design concept, by its label, as the design the build plan and app are built from. Ask about the concept they picked, or the one you recommend when they have not said. Once they approve, the concept's newest option, with any look tweaks they saved, is filed as the design, approved by them, and the other concepts' designers stop.",
inputSchema: { ...ChooseDesign.toJsonSchema() },
},
{
name: "deploy_presenter",
description:
Expand All @@ -319,6 +326,7 @@ export function createDeployTools(options?: {
{ name: "deploy_coder" },
{ name: "instruct_coder", approval: "ask" },
{ name: "design_app", approval: "ask" },
{ name: "choose_design", approval: "ask" },
{ name: "deploy_presenter" },
],
factory: (env) => ({
Expand Down Expand Up @@ -749,6 +757,19 @@ export function createDeployTools(options?: {
error: refusalText(outcome.reason),
},
);
} else if (call.name === "choose_design") {
// This handler runs only after the native approval resolved; the hub binds the choice to it.
try {
result = await hub(
projectPath + "/design-choice",
validArguments(ChooseDesign, call.arguments),
);
} catch (error) {
throw new Refusal(
refusalText(error) +
"; no design was filed, so ask the person again with choose_design",
);
}
} else if (call.name === "deploy_presenter") {
const input = validArguments(DeployPresenter, call.arguments);
const { byKind, approved, preferences } = await listed();
Expand Down
Loading