diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 2102251ef3..5b345e8da7 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -130,6 +130,7 @@ import { DeployProjectScreen } from "../handlers/project/deploy/screen.tsx"; import { ProjectCreateScreen } from "../handlers/project/create/screen.tsx"; import { ProjectInvokePickerScreen } from "../handlers/project/invoke/screen.tsx"; import { AddRuntimeScreen } from "../handlers/project/add/runtime/screen.tsx"; +import { AddRuntimeEndpointScreen } from "../handlers/project/add/runtime-endpoint/screen.tsx"; import { AddMemoryScreen } from "../handlers/project/add/memory/screen.tsx"; import { AddGatewayScreen } from "../handlers/project/add/gateway/screen.tsx"; import { AddGatewayTargetScreen } from "../handlers/project/add/gateway-target/screen.tsx"; @@ -914,6 +915,10 @@ function RouteTable({ ctx, core }: ScreenProps) { } /> } /> } /> + } + /> } /> } /> void; required?: boolean; schema?: z.ZodType; + // number makes validation fail unless the value is all digits, and hands the + // schema a Number rather than the string; typing itself is not restricted. + number?: boolean; }; export type MultiTextFieldProps = { @@ -465,6 +468,7 @@ export function MultiTextField({ inputs }: MultiTextFieldProps) { label: input.label, required: input.required ?? false, schema: input.schema, + number: input.number ?? false, }); useInput((_input, key) => { diff --git a/src/handlers/project/add/add.screen.test.tsx b/src/handlers/project/add/add.screen.test.tsx index 7d7c136327..b5a7260056 100644 --- a/src/handlers/project/add/add.screen.test.tsx +++ b/src/handlers/project/add/add.screen.test.tsx @@ -23,6 +23,7 @@ function addSubcommands(): string[] { // "cli" divider and opens its help instead. const WITH_SCREENS = [ "runtime", + "runtime-endpoint", "memory", "gateway", "gateway-target", diff --git a/src/handlers/project/add/index.ts b/src/handlers/project/add/index.ts index 3010516a4c..20f6136c29 100644 --- a/src/handlers/project/add/index.ts +++ b/src/handlers/project/add/index.ts @@ -29,6 +29,7 @@ export function createAddProjectResourceHandler( // CliOnlyScreen). const projectAdd = new Router("add", "add project resources").supportedTuiCommands( "runtime", + "runtime-endpoint", "memory", "gateway", "gateway-target", diff --git a/src/handlers/project/add/runtime-endpoint/index.ts b/src/handlers/project/add/runtime-endpoint/index.ts index a5fb5d6b93..110a4fd241 100644 --- a/src/handlers/project/add/runtime-endpoint/index.ts +++ b/src/handlers/project/add/runtime-endpoint/index.ts @@ -1,8 +1,38 @@ import z from "zod"; import { createHandler, flag, ProjectKey } from "../../../../router"; +import type { AddResourceInput } from "../../types"; import type { AddProjectResourceConfig } from "../types"; import { addProjectResource } from "../shared"; +// The version an endpoint points to when none is given — what --version +// documents as its default, and what the wizard prefills. +export const DEFAULT_ENDPOINT_VERSION = 1; + +// RuntimeEndpointInput is the endpoint as the flags state it. +export type RuntimeEndpointInput = { + runtime: string; + name: string; + version?: number; + description?: string; +}; + +type AddRuntimeEndpoint = Extract; + +// toAddRuntimeEndpointInput is the one place a runtime endpoint is built from +// user input — the flags, or the wizard's answers — so both default the version +// and drop an empty description the same way. +export function toAddRuntimeEndpointInput(input: RuntimeEndpointInput): AddRuntimeEndpoint { + return { + resourceType: "runtime-endpoint", + runtimeName: input.runtime, + resourceConfig: { + name: input.name, + version: input.version ?? DEFAULT_ENDPOINT_VERSION, + ...(input.description ? { description: input.description } : {}), + }, + }; +} + export const createAddRuntimeEndpointHandler = (config: AddProjectResourceConfig) => createHandler({ name: "runtime-endpoint", @@ -12,29 +42,26 @@ export const createAddRuntimeEndpointHandler = (config: AddProjectResourceConfig flag("name", "the endpoint name (e.g., prod, staging)", z.string().min(1)), flag( "version", - "the runtime version this endpoint points to (default: 1)", + `the runtime version this endpoint points to (default: ${DEFAULT_ENDPOINT_VERSION})`, z.number().int().min(1).optional(), ), flag("description", "description of the endpoint", z.string().optional()), ], handle: async (ctx, flags) => { const project = ctx.require(ProjectKey); - const version = flags.version ?? 1; + const input = toAddRuntimeEndpointInput({ + runtime: flags.runtime, + name: flags.name, + version: flags.version, + description: flags.description, + }); await addProjectResource( ctx, config, project, - { - resourceType: "runtime-endpoint", - runtimeName: flags.runtime, - resourceConfig: { - name: flags.name, - version, - ...(flags.description ? { description: flags.description } : {}), - }, - }, - `added runtime endpoint '${flags.name}' (version ${version}) to runtime '${flags.runtime}' in '${project.name}'`, + input, + `added runtime endpoint '${flags.name}' (version ${input.resourceConfig.version}) to runtime '${flags.runtime}' in '${project.name}'`, ); }, }); diff --git a/src/handlers/project/add/runtime-endpoint/runtime-endpoint.screen.test.tsx b/src/handlers/project/add/runtime-endpoint/runtime-endpoint.screen.test.tsx new file mode 100644 index 0000000000..7cb573193f --- /dev/null +++ b/src/handlers/project/add/runtime-endpoint/runtime-endpoint.screen.test.tsx @@ -0,0 +1,382 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + cleanupScreens, + createSilentLogger, + flatFrame, + initProject, + renderScreen, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, + ttyTestIO, + waitFor, + waitForFlatText, + waitForText, + type RenderScreenResult, +} from "../../../../testing"; +import { createRootHandler } from "../../../index"; +import { InputValidationError } from "../../../../errors"; +import type { AppIO } from "../../../../io"; +import { projectSpec, writeProjectSpec } from "../gateway-test-support"; +import { endpointNameSchema } from "./screen"; + +type Endpoints = Record; + +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); +afterEach(cleanupScreens); + +function buildRoot(io: AppIO) { + return createRootHandler(new TestCoreClient(), { + io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); +} + +async function run(args: string[]) { + const io = testIO(); + await buildRoot(io.io).route(["node", "agentcore", ...args]); + return io; +} + +// A minimal runtime is enough for an endpoint's parent; scaffolding a real one +// would install dependencies the endpoint path never touches. +function runtimeSpec(name: string, endpoints?: Endpoints) { + return { + name, + build: "CodeZip", + entrypoint: "main.py", + codeLocation: `app/${name}`, + runtimeVersion: "PYTHON_3_13", + ...(endpoints ? { endpoints } : {}), + }; +} + +// withRuntimes starts a project with exactly the given runtimes — none, when +// called bare — so the first step's list is known. +async function withRuntimes(...runtimes: ReturnType[]): Promise { + const { projectRoot, cleanup } = await initProject({ + prefix: "agentcore-add-runtime-endpoint-wizard-", + }); + cleanups.push(cleanup); + const spec = await projectSpec(projectRoot); + await writeProjectSpec(projectRoot, { ...spec, runtimes }); + return projectRoot; +} + +async function endpointsOf(projectRoot: string, runtime: string): Promise { + const spec = await projectSpec(projectRoot); + const runtimes = spec.runtimes as Array<{ name: string; endpoints?: Endpoints }>; + return runtimes.find((candidate) => candidate.name === runtime)?.endpoints; +} + +// reachDetailsStep confirms the first runtime and names the endpoint, leaving +// the wizard on the version step with its prefilled 1. +async function reachDetailsStep(screen: RenderScreenResult, name: string): Promise { + await waitForText(screen.lastFrame, "which Runtime should this endpoint belong to?"); + await screen.press("return"); + await waitForText(screen.lastFrame, "what should this endpoint be called?"); + await screen.write(name); + await screen.press("return"); + await waitForText(screen.lastFrame, "which Runtime version does it point to?"); +} + +describe("runtime-endpoint wizard helpers", () => { + test("endpointNameSchema applies the name rule and refuses a name the Runtime already has", () => { + const schema = endpointNameSchema(runtimeSpec("agent", { prod: { version: 1 } }) as never); + expect(schema.safeParse("staging").success).toBe(true); + expect(schema.safeParse("9lives").success).toBe(false); + const taken = schema.safeParse("prod"); + expect(taken.success).toBe(false); + expect(taken.error?.issues[0]?.message).toBe( + "an endpoint named 'prod' already exists on runtime 'agent'", + ); + // Without a runtime to check against, only the name rule applies. + expect(endpointNameSchema(undefined).safeParse("prod").success).toBe(true); + }); +}); + +describe("project add runtime-endpoint wizard", () => { + test("adds the same endpoint as the flags, on the Runtime picked from the list", async () => { + const projectRoot = await withRuntimes( + runtimeSpec("agent", { prod: { version: 1 } }), + runtimeSpec("worker"), + ); + const screen = renderScreen("/agentcore/add/runtime-endpoint"); + + // Each Runtime shows the endpoints it already has. + await waitForText(screen.lastFrame, "which Runtime should this endpoint belong to?"); + expect(screen.lastFrame()).toContain("❯ ● agent"); + expect(screen.lastFrame()).toContain("endpoint: prod"); + expect(screen.lastFrame()).toContain("no endpoints yet"); + await screen.press("down"); + await waitForText(screen.lastFrame, "❯ ● worker"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "what should this endpoint be called?"); + await screen.write("staging"); + await screen.press("return"); + + // Version and description share a step; the version is prefilled with 1. + await waitForText(screen.lastFrame, "which Runtime version does it point to?"); + expect(screen.lastFrame()).toContain("Version"); + expect(screen.lastFrame()).toContain("Description"); + await screen.press("backspace"); + await screen.write("3"); + await screen.press("return"); + await screen.write("Production traffic"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "this endpoint will be added to agentcore.json"); + const review = flatFrame(screen.lastFrame); + expect(review).toContain("runtime worker"); + expect(review).toContain("endpoint staging"); + expect(review).toContain("version 3"); + expect(review).toContain("description Production traffic"); + await screen.press("return"); + + await waitForFlatText( + screen.lastFrame, + "added runtime endpoint 'staging' (version 3) to runtime 'worker' in 'TestProject'", + ); + expect(screen.lastFrame()).toContain("agentcore deploy"); + expect(await endpointsOf(projectRoot, "worker")).toEqual({ + staging: { version: 3, description: "Production traffic" }, + }); + expect(await endpointsOf(projectRoot, "agent")).toEqual({ prod: { version: 1 } }); + + // The flags, given the same answers, write the same endpoint. + await run([ + "add", + "runtime-endpoint", + "--runtime", + "agent", + "--name", + "staging", + "--version", + "3", + "--description", + "Production traffic", + ]); + expect((await endpointsOf(projectRoot, "agent"))?.staging).toEqual( + (await endpointsOf(projectRoot, "worker"))?.staging, + ); + screen.unmount(); + }); + + test("version defaults to 1 and the description is optional", async () => { + const projectRoot = await withRuntimes(runtimeSpec("agent")); + const screen = renderScreen("/agentcore/add/runtime-endpoint"); + + await reachDetailsStep(screen, "staging"); + await screen.press("return"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "this endpoint will be added to agentcore.json"); + const review = flatFrame(screen.lastFrame); + expect(review).toContain("version 1"); + expect(review).toContain("description (none)"); + await screen.press("return"); + + await waitForFlatText( + screen.lastFrame, + "added runtime endpoint 'staging' (version 1) to runtime 'agent' in 'TestProject'", + ); + expect(await endpointsOf(projectRoot, "agent")).toEqual({ staging: { version: 1 } }); + screen.unmount(); + }); + + test("a name the chosen Runtime already has is refused as it is typed", async () => { + await withRuntimes(runtimeSpec("agent", { prod: { version: 2 } })); + const screen = renderScreen("/agentcore/add/runtime-endpoint"); + + await waitForText(screen.lastFrame, "which Runtime should this endpoint belong to?"); + await screen.press("return"); + await waitForText(screen.lastFrame, "what should this endpoint be called?"); + await screen.write("prod"); + await waitForText( + screen.lastFrame, + "an endpoint named 'prod' already exists on runtime 'agent'", + ); + await screen.press("return"); + expect(screen.lastFrame()).toContain("what should this endpoint be called?"); + + // Any other well-formed name goes through. + await screen.press("backspace"); + await screen.write("uction"); + await screen.press("return"); + await waitForText(screen.lastFrame, "which Runtime version does it point to?"); + screen.unmount(); + }); + + test("the version must be a whole number of at least 1", async () => { + await withRuntimes(runtimeSpec("agent")); + const screen = renderScreen("/agentcore/add/runtime-endpoint"); + + await reachDetailsStep(screen, "staging"); + await screen.press("backspace"); + await screen.press("return"); + await waitForText(screen.lastFrame, "Version is required"); + + await screen.write("0"); + await screen.press("return"); + await waitForText(screen.lastFrame, "Version must be 1 or higher"); + + await screen.press("backspace"); + await screen.write("v2"); + await screen.press("return"); + await waitForText(screen.lastFrame, "Version must be a whole number"); + + await screen.press("backspace"); + await screen.press("backspace"); + await screen.write("2"); + await screen.press("return"); + await screen.press("return"); + await waitForText(screen.lastFrame, "this endpoint will be added to agentcore.json"); + expect(flatFrame(screen.lastFrame)).toContain("version 2"); + screen.unmount(); + }); + + test("without a Runtime the first step says what to add and esc returns to the menu", async () => { + await withRuntimes(); + const screen = renderScreen("/agentcore/add/runtime-endpoint"); + + await waitForText(screen.lastFrame, "no Runtimes in this project"); + expect(screen.lastFrame()).toContain("agentcore add runtime"); + + await screen.press("escape"); + await waitForText(screen.lastFrame, "add project resources"); + screen.unmount(); + }); + + test("a rejected add reports itself and hands the form back", async () => { + const projectRoot = await withRuntimes(runtimeSpec("agent")); + const screen = renderScreen("/agentcore/add/runtime-endpoint"); + + await reachDetailsStep(screen, "prod"); + await screen.press("return"); + await screen.press("return"); + await waitForText(screen.lastFrame, "this endpoint will be added to agentcore.json"); + + // The name was free when typed; it is taken by the time the review submits. + await run([ + "add", + "runtime-endpoint", + "--runtime", + "agent", + "--name", + "prod", + "--version", + "4", + ]); + await screen.press("return"); + + await waitForFlatText( + screen.lastFrame, + "a runtime-endpoint named 'prod' already exists on runtime 'agent'", + ); + await screen.press("escape"); + await waitForText(screen.lastFrame, "this endpoint will be added to agentcore.json"); + expect(flatFrame(screen.lastFrame)).toContain("endpoint prod"); + + expect(await endpointsOf(projectRoot, "agent")).toEqual({ prod: { version: 4 } }); + screen.unmount(); + }); + + test("esc on the first step returns to the add menu", async () => { + await withRuntimes(runtimeSpec("agent")); + const screen = renderScreen("/agentcore/add/runtime-endpoint"); + + await waitForText(screen.lastFrame, "which Runtime should this endpoint belong to?"); + await screen.press("escape"); + + await waitForText(screen.lastFrame, "add project resources"); + screen.unmount(); + }); +}); + +describe("project add runtime-endpoint dispatch", () => { + const MISSING_RUNTIME = "required option '--runtime' not specified"; + + async function routeError(io: AppIO, args: string[]): Promise { + return buildRoot(io) + .route(["node", "agentcore", "add", "runtime-endpoint", ...args]) + .then( + () => undefined, + (caught: unknown) => caught, + ); + } + + test("bare add runtime-endpoint in a TTY session opens the wizard", async () => { + await withRuntimes(runtimeSpec("agent")); + const { streams, stdin } = ttyTestIO(); + + const outcome = buildRoot(streams.io) + .route(["node", "agentcore", "add", "runtime-endpoint"]) + .then( + () => ({ ok: true as const }), + (error: unknown) => ({ ok: false as const, error }), + ); + let settled = false; + void outcome.finally(() => { + settled = true; + }); + + await waitFor( + () => { + if (!settled) stdin.write("\x03"); + return settled; + }, + 5000, + 150, + ); + expect(await outcome).toEqual({ ok: true }); + expect(streams.stderr()).not.toContain("required option"); + }, 10000); + + test("bare add runtime-endpoint without a TTY stays headless and reports the missing --runtime", async () => { + await withRuntimes(runtimeSpec("agent")); + + const error = await routeError(testIO().io, []); + + expect(error).toBeInstanceOf(InputValidationError); + expect((error as Error).message).toContain(MISSING_RUNTIME); + }); + + test("any user-supplied flag stays headless even in a TTY", async () => { + await withRuntimes(runtimeSpec("agent")); + + const error = await routeError(ttyTestIO().streams.io, ["--name", "prod"]); + + expect(error).toBeInstanceOf(InputValidationError); + expect((error as Error).message).toContain(MISSING_RUNTIME); + }); + + test("--json stays headless even in a TTY", async () => { + await withRuntimes(runtimeSpec("agent")); + + const error = await routeError(ttyTestIO().streams.io, ["--json"]); + + expect(error).toBeInstanceOf(InputValidationError); + expect((error as Error).message).toContain(MISSING_RUNTIME); + }); + + test("flag-driven add runtime-endpoint still runs headless in a TTY session", async () => { + const projectRoot = await withRuntimes(runtimeSpec("agent")); + const { streams } = ttyTestIO(); + + await buildRoot(streams.io).route([ + "node", + "agentcore", + "add", + "runtime-endpoint", + "--runtime", + "agent", + "--name", + "prod", + ]); + + expect(await endpointsOf(projectRoot, "agent")).toEqual({ prod: { version: 1 } }); + }); +}); diff --git a/src/handlers/project/add/runtime-endpoint/screen.tsx b/src/handlers/project/add/runtime-endpoint/screen.tsx new file mode 100644 index 0000000000..8dd874ddb7 --- /dev/null +++ b/src/handlers/project/add/runtime-endpoint/screen.tsx @@ -0,0 +1,205 @@ +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router"; +import z from "zod"; +import { + MultiTextField, + ResourceChoiceField, + Step, + Summary, + TextField, + Wizard, + type Choice, +} from "../../../../components/wizard"; +import { + RuntimeEndpointNameSchema, + RuntimeEndpointSchema, + type ProjectRuntime, +} from "../../../../projectSchemas/runtime"; +import { ProjectKey } from "../../../../router"; +import type { ScreenProps } from "../../../types"; +import type { Project } from "../../types"; +import { ProjectGate, projectQueryKey } from "../../ProjectGate"; +import { + DEFAULT_ENDPOINT_VERSION, + toAddRuntimeEndpointInput, + type RuntimeEndpointInput, +} from "./index"; + +const BREADCRUMB = ["agentcore", "add", "runtime-endpoint"]; +const DESCRIPTION = "add a named endpoint (version alias) to a runtime"; +const ADD_MENU = "/agentcore/add"; + +// The version rule the spec applies, with a message that names the input; the +// whole-number check itself happens before the schema sees the value. +const VERSION_SCHEMA = z.number().int().min(1, "Version must be 1 or higher"); + +type RuntimeEndpointFormValues = { + runtime: string; + name: string; + version: string; + description: string; +}; + +// endpointNameSchema is the endpoint name rule plus the check the manager makes +// on submit — no two endpoints of one Runtime share a name — so a clash is +// caught as it is typed rather than after the review. +export function endpointNameSchema(runtime: ProjectRuntime | undefined): z.ZodType { + const taken = new Set(Object.keys(runtime?.endpoints ?? {})); + return RuntimeEndpointNameSchema.superRefine((name, ctx) => { + if (runtime !== undefined && taken.has(name)) { + ctx.addIssue({ + code: "custom", + message: `an endpoint named '${name}' already exists on runtime '${runtime.name}'`, + }); + } + }); +} + +// toRuntimeEndpointInput is the answers as the flag path would state them: the +// version parsed from its digits, a blank description left out. +export function toRuntimeEndpointInput(values: RuntimeEndpointFormValues): RuntimeEndpointInput { + const description = values.description.trim(); + return { + runtime: values.runtime, + name: values.name, + version: Number(values.version), + description: description === "" ? undefined : description, + }; +} + +function summaryOf(values: RuntimeEndpointFormValues): Record { + return { + runtime: values.runtime, + endpoint: values.name, + version: values.version, + description: values.description.trim() || "(none)", + }; +} + +// runtimeChoices lists each Runtime with the endpoints it already has, so the +// name step's uniqueness rule is no surprise. +function runtimeChoices(runtimes: readonly ProjectRuntime[]): Choice[] { + return runtimes.map((runtime) => { + const names = Object.keys(runtime.endpoints ?? {}); + return { + value: runtime.name, + label: runtime.name, + description: + names.length === 0 + ? "no endpoints yet" + : `${names.length === 1 ? "endpoint" : "endpoints"}: ${names.join(", ")}`, + }; + }); +} + +export function AddRuntimeEndpointScreen({ ctx, core }: ScreenProps) { + const navigate = useNavigate(); + return ( + navigate(ADD_MENU)} + > + {(project) => } + + ); +} + +function AddRuntimeEndpointWizard({ + project, + core, +}: { + project: Project; + core: ScreenProps["core"]; +}) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const runtimes = project.spec.runtimes; + const [values, setValues] = useState({ + runtime: runtimes[0]?.name ?? "", + name: "", + version: String(DEFAULT_ENDPOINT_VERSION), + description: "", + }); + const set = (update: Partial) => + setValues((current) => ({ ...current, ...update })); + const runtime = runtimes.find((candidate) => candidate.name === values.runtime); + + return ( + navigate(ADD_MENU)} + onSubmit={async function* () { + const updated = yield* core.projectManager.addResource( + project, + toAddRuntimeEndpointInput(toRuntimeEndpointInput(values)), + ); + queryClient.setQueryData(projectQueryKey(), updated); + return updated; + }} + runningLabel={`adding runtime endpoint ${values.name}…`} + successLabel={`added runtime endpoint '${values.name}' (version ${values.version}) to runtime '${values.runtime}' in '${project.name}'`} + successNextSteps={["agentcore deploy"]} + onDone={() => navigate(ADD_MENU)} + doneLabel="go back" + > + + set({ runtime })} + emptyMessage="no Runtimes in this project" + emptyHint="add one with agentcore add runtime" + /> + + + + set({ name })} + required + schema={endpointNameSchema(runtime)} + live + /> + + + + set({ version }), + required: true, + number: true, + schema: VERSION_SCHEMA, + }, + { + key: "description", + label: "Description", + help: "optional · up to 200 characters", + placeholder: "production traffic", + value: values.description, + onChange: (description) => set({ description }), + schema: RuntimeEndpointSchema.shape.description, + }, + ]} + /> + + + + + + + ); +}