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
48 changes: 30 additions & 18 deletions apps/hub/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,18 +360,6 @@ export async function createHubRuntime(
coordinatorStartTimeoutMs,
workflows.services.workflowLifecycleService.requestCancellation,
);
const coordinatorUpgrades = createCoordinatorUpgrades({
db: handle.db,
runs: createWorkflowRunReader(agentRepoStore.repoStore),
installed: (actor, projectId) =>
installForTenant(
{ db: handle.db, assets: assetService },
actor,
projectId,
),
startup: coordinatorStartup,
cancel: workflows.services.workflowLifecycleService.requestCancellation,
});
const sidecarWebSocket = createSidecarWebSocket(sidecarRouter);
const agentActivity = createAgentActivity(sidecarRouter.events);
const getSession: GetSession = async (headers) => {
Expand All @@ -388,6 +376,24 @@ export async function createHubRuntime(
const sessionCookieName = (await auth.$context).authCookies.sessionToken
.name;
const app = new Hono<AppEnv>();
const hubMail = createHubMail(
handle.db,
async (request) => app.fetch(request),
env.hubApiOrigin,
);
const coordinatorUpgrades = createCoordinatorUpgrades({
db: handle.db,
runs: createWorkflowRunReader(agentRepoStore.repoStore),
installed: (actor, projectId) =>
installForTenant(
{ db: handle.db, assets: assetService },
actor,
projectId,
),
startup: coordinatorStartup,
cancel: workflows.services.workflowLifecycleService.requestCancellation,
mail: hubMail,
});
const liveAppProxy = createLiveAppProxy({
hostname: env.hostname,
ports: env.liveAppPorts,
Expand Down Expand Up @@ -419,7 +425,15 @@ export async function createHubRuntime(
app.use(liveAppProxy.refuseAppOrigins);
app.use(createHubContextMiddleware({ getSession }));
app.use(gitTokens.middleware);
const coordinatorTokens = createAgentTokenBridge(handle.db, runTokenStore);
const coordinatorTokens = createAgentTokenBridge(handle.db, runTokenStore, {
install: (actor, projectId) =>
installForTenant(
{ db: handle.db, assets: assetService },
actor,
projectId,
),
handoff: coordinatorUpgrades.handoff,
});
app.use("/api/tenants/:tenantId/*", coordinatorTokens.middleware);
app.use("/api/workflow-project-scope", coordinatorTokens.middleware);
app.use(
Expand Down Expand Up @@ -499,6 +513,9 @@ export async function createHubRuntime(
createConversationRoutes(handle.db, (request) => app.fetch(request), {
repoStore: agentRepoStore.repoStore,
mailDirectory: reportMailDirectory,
...(coordinatorUpgrade.enabled
? { upgrade: coordinatorUpgrades.open }
: {}),
}),
);
app.route(
Expand Down Expand Up @@ -618,11 +635,6 @@ export async function createHubRuntime(
500,
);
});
const hubMail = createHubMail(
handle.db,
async (request) => app.fetch(request),
env.hubApiOrigin,
);
const projectSchedulers = [
createReconciliationScheduler({
name: "Specialist end notice",
Expand Down
5 changes: 4 additions & 1 deletion apps/hub/src/artifacts/approval-package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,10 @@ describe("approval package validation and secret scanning", () => {
domain: "example.invalid",
role: "coordinator",
workflows: {
writer: deployment,
"writer-constraints": deployment,
"writer-approaches": deployment,
"writer-product-requirements": deployment,
"writer-build-plan": deployment,
reviewer: deployment,
coder: deployment,
designer: deployment,
Expand Down
10 changes: 8 additions & 2 deletions apps/hub/src/artifacts/design-draft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,10 @@ test("file_design_option packs per-screen files and drops the untouched starter
domain: "example.invalid",
role: "designer",
workflows: {
writer: deployment,
"writer-constraints": deployment,
"writer-approaches": deployment,
"writer-product-requirements": deployment,
"writer-build-plan": deployment,
reviewer: deployment,
coder: deployment,
designer: deployment,
Expand Down Expand Up @@ -456,7 +459,10 @@ test("designer agent writes per-screen files, is held by checkBeforeDone until c
domain: "example.invalid",
role: "designer",
workflows: {
writer: deployment,
"writer-constraints": deployment,
"writer-approaches": deployment,
"writer-product-requirements": deployment,
"writer-build-plan": deployment,
reviewer: deployment,
coder: deployment,
designer: deployment,
Expand Down
5 changes: 3 additions & 2 deletions apps/hub/src/artifacts/service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { stat } from "node:fs/promises";
import { dirname, join } from "node:path";
import { type } from "arktype";
import { and, desc, eq, inArray, ne, sql } from "drizzle-orm";
import { and, desc, eq, inArray, ne, notInArray, sql } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import {
createArtifact,
Expand Down Expand Up @@ -975,7 +975,8 @@ export function createArtifactService(
.where(
and(
eq(approval.tenantId, actor.tenantId),
ne(approval.status, "pending"),
// An expired ask is one a coordinator upgrade withdrew; its successor asks again.
notInArray(approval.status, ["pending", "expired"]),
),
)
.orderBy(approval.createdAt);
Expand Down
158 changes: 84 additions & 74 deletions apps/hub/src/artifacts/tools.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { expect, test } from "bun:test";
import { type } from "arktype";
import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities";
import { WriterDocumentKind } from "@corbits-builder/schemas/artifact";
import type { WorkflowRole } from "@corbits-builder/schemas/installed-workflows";
import { createArtifactTools } from "./tools.ts";
import { createArtifactTools, createWriterArtifactTools } from "./tools.ts";

const deployment = {
source: {
Expand Down Expand Up @@ -41,7 +43,28 @@ const sampleApproaches = {
recommended: "simple",
};

function harness(role: WorkflowRole) {
// The fields each writer's save takes.
const WRITER_FIELDS: Record<WriterDocumentKind, string[]> = {
constraints: ["content", "kind", "openQuestions", "title"],
approaches: ["approaches", "content", "kind", "openQuestions", "title"],
product_requirements: ["content", "kind", "openQuestions", "title"],
build_plan: ["content", "kind", "openQuestions", "title"],
};
const WRITER_KINDS = Object.keys(WRITER_FIELDS).map((kind) =>
WriterDocumentKind.assert(kind),
);
// A provider that reads only top-level properties sees nothing to fill in a union and sends {}.
const FlatToolSchema = type({
type: "'object'",
additionalProperties: "false",
properties: "Record<string, object>",
"anyOf?": "never",
});

function harness(
role: WorkflowRole,
tools: ReturnType<typeof createArtifactTools>,
) {
const written: unknown[] = [];
const fetch = async (path: string, init: RequestInit) => {
if (path === "/api/workflow-project-scope")
Expand All @@ -51,7 +74,10 @@ function harness(role: WorkflowRole) {
domain: "example.invalid",
role,
workflows: {
writer: deployment,
"writer-constraints": deployment,
"writer-approaches": deployment,
"writer-product-requirements": deployment,
"writer-build-plan": deployment,
reviewer: deployment,
coder: deployment,
designer: deployment,
Expand Down Expand Up @@ -83,7 +109,7 @@ function harness(role: WorkflowRole) {
const capabilities = {
resolve: () => ({ resolve: async () => ({ kind: "http", fetch }) }),
} as unknown as RuntimeCapabilities;
const instance = createArtifactTools(role)({ capabilities } as never);
const instance = tools({ capabilities } as never);
return {
definitions: instance.definitions,
written,
Expand All @@ -96,7 +122,10 @@ function harness(role: WorkflowRole) {
}

test("coordinator write_artifact exposes only problem_brief without approaches", async () => {
const coordinator = harness("coordinator");
const coordinator = harness(
"coordinator",
createArtifactTools("coordinator"),
);
const definition = coordinator.definitions.find(
(item) => item.name === "write_artifact",
);
Expand Down Expand Up @@ -139,96 +168,77 @@ test("coordinator write_artifact exposes only problem_brief without approaches",
});

test("every role's artifact tool definitions declare top-level type: object", () => {
const roles: WorkflowRole[] = [
"coordinator",
"writer",
"reviewer",
"coder",
"designer",
const tools = [
...(["coordinator", "reviewer", "coder", "designer"] as const).map((role) =>
harness(role, createArtifactTools(role)),
),
...WRITER_KINDS.map((kind) =>
harness("writer", createWriterArtifactTools(kind)),
),
];
for (const role of roles) {
for (const definition of harness(role).definitions) {
for (const tool of tools) {
for (const definition of tool.definitions) {
expect(
(definition.inputSchema as { type?: unknown } | undefined)?.type,
).toBe("object");
}
}
});

test("writer write_artifact exposes only writer kinds and validates approaches", async () => {
const writer = harness("writer");
const definition = writer.definitions.find(
(item) => item.name === "write_artifact",
);
expect(
(definition?.inputSchema as { type?: unknown } | undefined)?.type,
).toBe("object");
const branches = (
definition?.inputSchema as
| {
anyOf?: {
additionalProperties?: boolean;
properties?: {
kind?: { const?: string };
approaches?: unknown;
};
}[];
}
| undefined
)?.anyOf;
const branchFor = (kind: string) =>
branches?.find((branch) => branch.properties?.kind?.const === kind);
expect(branches).toHaveLength(4);
const approaches = branchFor("approaches");
expect(approaches?.additionalProperties).toBe(false);
expect(Object.keys(approaches?.properties ?? {}).sort()).toEqual([
"approaches",
"content",
"kind",
"openQuestions",
"title",
]);
for (const kind of ["constraints", "product_requirements", "build_plan"]) {
const branch = branchFor(kind);
expect(branch?.additionalProperties).toBe(false);
expect(Object.keys(branch?.properties ?? {}).sort()).toEqual([
"content",
"kind",
"openQuestions",
"title",
]);
test("each writer's write_artifact lists only its own fields, at the top level", () => {
for (const [kind, fields] of Object.entries(WRITER_FIELDS)) {
const tools = harness(
"writer",
createWriterArtifactTools(WriterDocumentKind.assert(kind)),
);
const [definition] = tools.definitions;
const schema = FlatToolSchema.assert(definition?.inputSchema);
expect(Object.keys(schema.properties).sort()).toEqual(fields);
}
expect(branchFor("problem_brief")).toBeUndefined();
});

const briefAttempt = await writer.write({
kind: "problem_brief",
title: "Problem Brief",
content: "# Problem Brief",
});
expect(briefAttempt.isError).toBe(true);
expect(briefAttempt.content).toStartWith("invalid_arguments:");
test("a writer files under the kind it was deployed for, with or without naming it", async () => {
for (const kind of WRITER_KINDS) {
const writer = harness("writer", createWriterArtifactTools(kind));
const otherKind = await writer.write({
kind: WRITER_KINDS.find((other) => other !== kind),
title: "Doc",
content: "# Doc",
});
expect(otherKind.content).toStartWith("invalid_arguments:");
const empty = await writer.write({ title: "Doc", content: "" });
expect(empty.content).toStartWith("invalid_arguments:");
expect(writer.written).toHaveLength(0);

await writer.write({ title: "Unnamed", content: "# Doc" });
await writer.write({ kind, title: "Named", content: "# Doc" });
expect(writer.written).toEqual([
{ kind, title: "Unnamed", content: "# Doc" },
{ kind, title: "Named", content: "# Doc" },
]);
}
});

for (const kind of ["constraints", "product_requirements", "build_plan"]) {
const rejected = await writer.write({
kind,
test("only the approaches writer saves approaches", async () => {
for (const kind of WRITER_KINDS.filter((kind) => kind !== "approaches")) {
const writer = harness("writer", createWriterArtifactTools(kind));
const result = await writer.write({
title: "Doc",
content: "# Doc",
approaches: sampleApproaches,
});
expect(rejected.isError).toBe(true);
expect(rejected.content).toStartWith("invalid_arguments:");
expect(rejected.content).toContain("approaches");
expect(result.content).toStartWith("invalid_arguments:");
expect(result.content).toContain("approaches");
}
expect(writer.written).toHaveLength(0);

const validApproaches = await writer.write({
kind: "approaches",
const approaches = harness("writer", createWriterArtifactTools("approaches"));
const valid = await approaches.write({
title: "Approaches",
content: "# Approaches",
approaches: sampleApproaches,
});
expect(validApproaches.isError).toBeUndefined();
expect(writer.written).toEqual([
expect(valid.isError).toBeUndefined();
expect(approaches.written).toEqual([
{
kind: "approaches",
title: "Approaches",
Expand Down
Loading