diff --git a/src/components/ConfirmAction.test.tsx b/src/components/ConfirmAction.test.tsx new file mode 100644 index 000000000..85f3df7ae --- /dev/null +++ b/src/components/ConfirmAction.test.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { afterEach, expect, test } from "bun:test"; +import { cleanup, render } from "ink-testing-library"; +import { MemoryRouter } from "react-router"; +import { waitForText } from "../testing"; +import { ConfirmAction, type ActionTrigger } from "./ConfirmAction"; + +afterEach(cleanup); + +test("resolves the trigger after loading and does not skip a late confirmation", async () => { + let calls = 0; + const action = async () => { + calls += 1; + return { rows: {} }; + }; + const view = (isPending: boolean, trigger: ActionTrigger) => ( + + {}} + /> + + ); + + const screen = render(view(true, { kind: "immediate" })); + screen.rerender(view(false, { kind: "confirm", message: "Delete everything?" })); + + await waitForText(screen.lastFrame, "Delete everything?"); + expect(calls).toBe(0); +}); diff --git a/src/components/ConfirmAction.tsx b/src/components/ConfirmAction.tsx index cc5541842..a9407e85c 100644 --- a/src/components/ConfirmAction.tsx +++ b/src/components/ConfirmAction.tsx @@ -1,74 +1,127 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Box, Text, useInput } from "ink"; import { useNavigate } from "react-router"; import { Layout } from "./Layout"; import { Spinner } from "./ui/spinner"; import { Confirm } from "./ui/confirm"; +import { TaskList, type Task } from "./ui/task-list"; +import { KeyValueTable } from "./KeyValueTable"; import { darkTheme } from "./ui/_core.js"; +import { driveProgress, type ProgressEvent } from "../tui/progress"; const theme = darkTheme; -export interface SummaryRow { - label: string; - value: string; +export type SummaryRows = Record; + +export interface ActionResult { + title?: string; + rows: SummaryRows; } +// ActionTrigger says what starts the action: a y/N question the user answers +// (destructive actions default to No), or nothing — it runs as soon as the +// summary has loaded, for an operation that is safe to start unasked. +export type ActionTrigger = { kind: "confirm"; message: string } | { kind: "immediate" }; + export interface ConfirmActionProps { // breadcrumb labels the screen. breadcrumb: string[]; + // description is shown dimmed after the breadcrumb. + description?: string; // title heads the summary overlay (usually the resource name). - title: string; - // rows describe the resource the action applies to. - rows: SummaryRow[]; - // message is the yes/no question (destructive actions default to No). - message: string; + title?: string; + // rows describe the resource the action applies to. With neither title nor + // rows the overlay is omitted. + rows?: SummaryRows; + trigger: ActionTrigger; // isPending / error reflect the summary fetch backing the overlay. isPending: boolean; error: Error | null; - // action performs the confirmed operation and resolves to result rows shown - // on the success panel. - action: () => Promise; - // successTitle heads the success panel (e.g. "Harness deleted"). + // action performs the confirmed operation and resolves to the result rows, + // optionally with a title overriding successTitle for an outcome only known + // afterwards. A progress generator (what runWithProgress drives) may be + // returned instead; its steps render as a live TaskList while it runs. + action: () => Promise | AsyncGenerator; + // successTitle heads the success panel (e.g. "Harness deleted") unless the + // action's result carries its own. successTitle: string; - // runningLabel is the spinner label while the action runs. + // runningLabel is the spinner label while the action runs, until its first + // progress step arrives. runningLabel: string; - // onDone is called when the user acknowledges the success panel. + // nextSteps are commands suggested under the success panel. + nextSteps?: string[]; + // onDone is called when the user acknowledges the success panel; doneLabel + // is the footer's word for it ("continue" by default). onDone: () => void; + doneLabel?: string; + // onCancel runs when the confirmation is declined or esc is pressed; defaults + // to popping the router history. + onCancel?: () => void; } +// "waiting" snapshots the trigger once the summary is ready. A confirmation's +// message then stays fixed through retries, even if a caller's props change. type Phase = - | { kind: "confirm" } + | { kind: "waiting" } + | { kind: "confirm"; message: string } | { kind: "running" } - | { kind: "success"; rows: SummaryRow[] } - | { kind: "error"; message: string }; + | { kind: "success"; title: string; rows: SummaryRows } + | { kind: "error"; message: string; retryMessage?: string }; // ConfirmAction is the shared destructive-action screen body: a summary overlay // of the target resource, a y/N confirmation (defaulting to No), a spinner // while the action runs, and a success/error panel. Cancel and esc pop back. export function ConfirmAction({ breadcrumb, + description, title, - rows, - message, + rows = {}, + trigger, isPending, error, action, successTitle, runningLabel, + nextSteps, onDone, + doneLabel = "continue", + onCancel, }: ConfirmActionProps) { const navigate = useNavigate(); - const [phase, setPhase] = useState({ kind: "confirm" }); + const cancel = onCancel ?? (() => navigate(-1)); + const [phase, setPhase] = useState({ kind: "waiting" }); + // tasks is the step list a progress-reporting action builds up; it stays on + // screen through success and error. + const [tasks, setTasks] = useState([]); - const run = async () => { + const run = async (retryMessage?: string) => { setPhase({ kind: "running" }); + setTasks([]); try { - setPhase({ kind: "success", rows: await action() }); + const result = action(); + const outcome = isProgressGenerator(result) + ? await driveProgress(result, setTasks) + : await result; + setPhase({ kind: "success", title: outcome.title ?? successTitle, rows: outcome.rows }); } catch (err) { - setPhase({ kind: "error", message: err instanceof Error ? err.message : String(err) }); + setPhase({ + kind: "error", + message: err instanceof Error ? err.message : String(err), + retryMessage, + }); } }; + // Resolve the latest trigger only after the summary is ready. This prevents a + // destructive action from inheriting an earlier immediate trigger while its + // data was still loading. + useEffect(() => { + if (phase.kind !== "waiting" || isPending || error) return; + if (trigger.kind === "confirm") setPhase({ kind: "confirm", message: trigger.message }); + else void run(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- the phase guard makes this run once + }, [phase.kind, isPending, error, trigger.kind]); + const hints = phase.kind === "confirm" ? [ @@ -77,50 +130,70 @@ export function ConfirmAction({ { key: "ctl+c", label: "quit" }, ] : phase.kind === "success" - ? [{ key: "enter", label: "continue" }] - : [ - { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, - ]; + ? [{ key: "enter", label: doneLabel }] + : phase.kind === "error" + ? [ + { key: "esc", label: "back" }, + { key: "ctl+c", label: "quit" }, + ] + : // Nothing listens for esc while the action runs (or is about to): + // an operation in flight is not abandoned by leaving the screen. + [{ key: "ctl+c", label: "quit" }]; return ( - + {isPending ? ( ) : error ? ( - navigate(-1)} /> + ) : ( - - {title} - {rows.map((row) => ( - - {row.label.padEnd(8)} - {row.value} - - ))} - + {(title !== undefined || Object.keys(rows).length > 0) && ( + + {title !== undefined && {title}} + {Object.keys(rows).length > 0 && } + + )} {phase.kind === "confirm" && ( navigate(-1)} + onConfirm={() => run(phase.message)} + onCancel={cancel} /> )} - {phase.kind === "running" && } + {phase.kind !== "confirm" && tasks.length > 0 && ( + + + + )} + {phase.kind === "running" && tasks.length === 0 && } {phase.kind === "success" && ( - + )} {phase.kind === "error" && ( - setPhase({ kind: "confirm" })} /> + // Without a question to return to, returning would run again. + setPhase({ kind: "confirm", message: phase.retryMessage! }) + : cancel + } + /> )} )} @@ -128,14 +201,28 @@ export function ConfirmAction({ ); } +// A promise has no Symbol.asyncIterator, so this is a safe discriminator. +function isProgressGenerator( + result: Promise | AsyncGenerator, +): result is AsyncGenerator { + return ( + typeof (result as AsyncGenerator)[Symbol.asyncIterator] === + "function" + ); +} + function SuccessBody({ title, rows, + nextSteps, onDone, + doneLabel, }: { title: string; - rows: SummaryRow[]; + rows: SummaryRows; + nextSteps?: string[]; onDone: () => void; + doneLabel: string; }) { useInput((_input, key) => { if (key.return || key.escape) onDone(); @@ -146,17 +233,22 @@ function SuccessBody({ ✔ {title} - - {rows.map((row) => ( - - {row.label.padEnd(8)} - {row.value} - - ))} - + {Object.keys(rows).length > 0 && ( + + + + )} + {nextSteps !== undefined && nextSteps.length > 0 && ( + + next steps + {nextSteps.map((step) => ( + {` ${step}`} + ))} + + )} - press enter to continue + press enter to {doneLabel} diff --git a/src/components/Root.tsx b/src/components/Root.tsx index b05b27bef..f20265659 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -108,6 +108,8 @@ import { GatewayRuleListScreen } from "../handlers/gateway/rule/list/screen.tsx" import { GatewayRuleGetScreen } from "../handlers/gateway/rule/get/screen.tsx"; import { GatewayInvokeScreen } from "../handlers/gateway/invoke/screen.tsx"; import { ProjectScreen, ProjectCommandNotImplementedScreen } from "../handlers/project/screen.tsx"; +import { BuildProjectScreen } from "../handlers/project/build/screen.tsx"; +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 { RootScreen, HelpScreen } from "../handlers/screen.tsx"; @@ -116,7 +118,7 @@ import type { Context } from "../router"; // PROJECT_COMMANDS are the `agentcore project` subcommands that are listed in // the menu but have no screen of their own yet (`create` has the wizard). Each // is routed explicitly so selecting it reports "not implemented" error -const PROJECT_COMMANDS = ["add", "export", "remove", "dev", "deploy", "status", "build"] as const; +const PROJECT_COMMANDS = ["add", "export", "remove", "dev", "status"] as const; export interface RootProps { // path is the command path to the executing node (e.g. "/agentcore"). @@ -749,6 +751,14 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { element={} /> } /> + } + /> + } + /> } diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 82dbfe265..78e994399 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -873,7 +873,7 @@ export class FsProjectManager implements ProjectManager { ): AsyncGenerator { const targetsPath = join(project.rootPath, "agentcore", "aws-targets.json"); const fileExists = existsSync(targetsPath); - const targets = fileExists ? await this.json.read(targetsPath, AwsDeploymentTargetsSchema) : []; + const targets = await this.listTargets(project); let target = targets.find((candidate) => candidate.name === input.target); @@ -919,13 +919,17 @@ export class FsProjectManager implements ProjectManager { // A read-only lookup, so callers (e.g. the deploy handler's up-front teardown // confirmation) can name the target's account and region without triggering // the default-target provisioning deploy performs. + public async listTargets(project: Project): Promise { + const targetsPath = join(project.rootPath, "agentcore", "aws-targets.json"); + if (!existsSync(targetsPath)) return []; + return this.json.read(targetsPath, AwsDeploymentTargetsSchema); + } + public async resolveTarget( project: Project, input: ResolveTargetInput, ): Promise { - const targetsPath = join(project.rootPath, "agentcore", "aws-targets.json"); - if (!existsSync(targetsPath)) return undefined; - const targets = await this.json.read(targetsPath, AwsDeploymentTargetsSchema); + const targets = await this.listTargets(project); return targets.find((candidate) => candidate.name === input.target); } diff --git a/src/handlers/harness/delete/screen.tsx b/src/handlers/harness/delete/screen.tsx index 4bb46915f..9d74bd921 100644 --- a/src/handlers/harness/delete/screen.tsx +++ b/src/handlers/harness/delete/screen.tsx @@ -39,20 +39,25 @@ function DeleteConfirm({ ctx, core, harnessId }: ScreenProps & { harnessId: stri { const response = await core.harness.deleteHarness({ harnessId }, opts); - return [ - { label: "id", value: response.harness?.harnessId ?? harnessId }, - { label: "status", value: response.harness?.status ?? "DELETING" }, - ]; + return { + rows: { + id: response.harness?.harnessId ?? harnessId, + status: response.harness?.status ?? "DELETING", + }, + }; }} successTitle="Harness deletion started" runningLabel="Deleting harness…" diff --git a/src/handlers/harness/endpoint/delete/screen.tsx b/src/handlers/harness/endpoint/delete/screen.tsx index 122ccf987..cabb5036b 100644 --- a/src/handlers/harness/endpoint/delete/screen.tsx +++ b/src/handlers/harness/endpoint/delete/screen.tsx @@ -57,12 +57,15 @@ function DeleteConfirm({ { @@ -70,10 +73,12 @@ function DeleteConfirm({ { harnessId, endpointName }, opts, ); - return [ - { label: "name", value: response.endpoint?.endpointName ?? endpointName }, - { label: "status", value: response.endpoint?.status ?? "DELETING" }, - ]; + return { + rows: { + name: response.endpoint?.endpointName ?? endpointName, + status: response.endpoint?.status ?? "DELETING", + }, + }; }} successTitle="Endpoint deletion started" runningLabel="Deleting endpoint…" diff --git a/src/handlers/project/ProjectGate.tsx b/src/handlers/project/ProjectGate.tsx new file mode 100644 index 000000000..4be2cfc2a --- /dev/null +++ b/src/handlers/project/ProjectGate.tsx @@ -0,0 +1,113 @@ +import React from "react"; +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { Box, Text, useInput } from "ink"; +import { Layout } from "../../components/Layout"; +import { Spinner } from "../../components/ui/spinner"; +import { darkTheme } from "../../components/ui/_core.js"; +import { ProjectStateError } from "../../errors/errors"; +import { projectNotFoundMessage } from "../../middleware/withProject"; +import type { Core } from "../types"; +import type { Project } from "./types"; + +const theme = darkTheme; + +// useProject resolves the project enclosing the cwd for a TUI screen. Screens +// resolve it themselves because withProject wraps `handle` only, and navigating +// between screens never executes a command — ProjectKey is set only when the +// launching command was a project command, in which case pass it as `seed`. +export function useProject(core: Core, seed?: Project): UseQueryResult { + const from = process.cwd(); + return useQuery({ + queryKey: ["project", from], + queryFn: async () => { + const project = await core.projectManager.resolve({ filePath: from }); + if (!project) throw new ProjectStateError(projectNotFoundMessage(from)); + return project; + }, + gcTime: 0, + // A seeded project is authoritative — it is what the launching command ran + // against — so it is never refetched from the cwd. + ...(seed && { initialData: seed, staleTime: Infinity }), + }); +} + +export interface LoadingFrameProps { + breadcrumb: string[]; + description?: string; + // query is whatever the screen is waiting on. + query: Pick; + loadingLabel: string; + onBack: () => void; +} + +// LoadingFrame is the spinner-or-error a screen shows before its data arrives: +// esc leaves, and on an error `r` tries again — the PaginatedTablePicker keys. +export function LoadingFrame({ + breadcrumb, + description, + query, + loadingLabel, + onBack, +}: LoadingFrameProps) { + useInput((input, key) => { + if (key.escape) onBack(); + if (query.isError && input === "r") void query.refetch(); + }); + + return ( + + + {query.isError ? ( + ✗ {(query.error as Error).message} + ) : ( + + )} + + + ); +} + +export interface ProjectGateProps { + core: Core; + breadcrumb: string[]; + description?: string; + // seed is the project already pinned on the launch context, when the command + // that opened the TUI was itself a project command. + seed?: Project; + onBack: () => void; + // children receives the resolved project and returns the screen. It must + // return an element rather than call hooks itself — the gate renders a + // spinner on the first paint, so a hook called here would change order. + children: (project: Project) => React.ReactElement; +} + +// ProjectGate resolves the project before rendering a project screen, showing +// the same not-found guidance the CLI prints when there is none. +export function ProjectGate({ + core, + breadcrumb, + description, + seed, + onBack, + children, +}: ProjectGateProps) { + const project = useProject(core, seed); + if (project.data !== undefined) return children(project.data); + return ( + + ); +} diff --git a/src/handlers/project/build/index.ts b/src/handlers/project/build/index.ts index 8f2f0883c..455493d1d 100644 --- a/src/handlers/project/build/index.ts +++ b/src/handlers/project/build/index.ts @@ -4,13 +4,18 @@ import { JsonRendererKey } from "../../../tui"; import { runWithProgress } from "../../../tui/progress"; import { JsonKey } from "../../keys"; import { renderJsonError } from "../../utils"; -import type { ProjectManager } from "../types"; +import type { Project, ProjectManager } from "../types"; type BuildProjectHandlerConfig = { projectManager: ProjectManager; io: AppIO; }; +/** The line both entry points print once a build finishes. */ +export function builtMessage(project: Project): string { + return `Built project '${project.name}'`; +} + export const createBuildProjectHandler = (config: BuildProjectHandlerConfig) => createHandler({ name: "build", @@ -35,7 +40,7 @@ export const createBuildProjectHandler = (config: BuildProjectHandlerConfig) => throw error; } - const message = `Built project '${project.name}'`; + const message = builtMessage(project); config.io.stderr.write(`${message}\n`); if (jsonOutput) ctx.require(JsonRendererKey).renderJson({ message }); }, diff --git a/src/handlers/project/build/screen.tsx b/src/handlers/project/build/screen.tsx new file mode 100644 index 000000000..2257f0805 --- /dev/null +++ b/src/handlers/project/build/screen.tsx @@ -0,0 +1,53 @@ +import { useNavigate } from "react-router"; +import { ConfirmAction } from "../../../components/ConfirmAction"; +import { ProjectKey } from "../../../router"; +import type { ScreenProps } from "../../types"; +import { ProjectGate } from "../ProjectGate"; +import type { Project } from "../types"; +import { builtMessage } from "./index"; + +const BREADCRUMB = ["agentcore", "project", "build"]; +const DESCRIPTION = "build the project's deployable artifacts"; +const PROJECT_MENU = "/agentcore/project"; + +// BuildProjectScreen runs the same projectManager.build generator the command +// runs; ConfirmAction renders its steps through the same TaskList. +export function BuildProjectScreen({ ctx, core }: ScreenProps) { + const navigate = useNavigate(); + return ( + navigate(PROJECT_MENU)} + > + {(project) => } + + ); +} + +function BuildConfirm({ project, core }: { project: Project; core: ScreenProps["core"] }) { + const navigate = useNavigate(); + + // No confirmation: a build changes nothing outside the project directory. + return ( + navigate(PROJECT_MENU)} + doneLabel="go back" + onCancel={() => navigate(PROJECT_MENU)} + /> + ); +} diff --git a/src/handlers/project/buildDeploy.screen.test.tsx b/src/handlers/project/buildDeploy.screen.test.tsx new file mode 100644 index 000000000..052b153e9 --- /dev/null +++ b/src/handlers/project/buildDeploy.screen.test.tsx @@ -0,0 +1,381 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { QueryClient } from "@tanstack/react-query"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import type { DeployBackendInput, ProjectBackend } from "../../core/project"; +import { createRootHandler } from "../index"; +import { + cleanupScreens, + createSilentLogger, + flatFrame, + renderScreen, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, + waitForFlatText, + waitForText, +} from "../../testing"; +import type { DeployResult, Project, ProjectEvent } from "./types"; + +// The build and deploy screens run the same ProjectManager generators the +// commands run, so these tests stub the backend exactly as the handler tests +// do and assert the same steps come out — through the TaskList this time. + +const EVENTS: ProjectEvent[] = [ + { type: "step", message: "Synthesizing CloudFormation templates" }, + { type: "output", line: "cdk synth: 3 stacks" }, + { type: "step", message: "Deploying stack" }, + { type: "output", line: "CREATE_IN_PROGRESS | AWS::IAM::Role" }, +]; + +type FakeBackendOptions = { + events?: ProjectEvent[]; + failure?: Error; + result?: DeployResult; +}; + +function fakeBackend(options: FakeBackendOptions = {}) { + const deploys: { project: Project; input: DeployBackendInput; confirmed?: boolean }[] = []; + const backend: ProjectBackend = { + async *build() { + yield* options.events ?? EVENTS; + if (options.failure) throw options.failure; + }, + async *deploy(project, input) { + const call: (typeof deploys)[number] = { project, input }; + deploys.push(call); + call.confirmed = await input.confirmTeardown({ + projectName: project.name, + targetName: input.target.name, + resourceDescription: "the stack", + account: input.target.account, + region: input.target.region, + }); + yield* options.events ?? EVENTS; + if (options.failure) throw options.failure; + return options.result ?? { outputs: { RuntimeArn: "arn:runtime" } }; + }, + async resolveDeployedResources() { + return []; + }, + }; + return { backend, deploys }; +} + +const originalCwd = process.cwd(); +const tempDirectories: string[] = []; + +afterEach(cleanupScreens); +afterEach(async () => { + process.chdir(originalCwd); + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +/** Scaffolds project 'orders' with a default target and cds into it. */ +const STAGING = { name: "staging", account: "444455556666", region: "eu-west-1" } as const; + +async function inProject( + core: TestCoreClient, + options: { empty?: boolean; targets?: boolean; staging?: boolean } = {}, +): Promise { + const directory = await mkdtemp(join(tmpdir(), "agentcore-build-deploy-screen-")); + tempDirectories.push(directory); + process.chdir(directory); + const root = createRootHandler(core, { + io: testIO().io, + globalConfigAccessor: new TestGlobalConfigAccessor(), + logger: createSilentLogger(), + }); + await root.route([ + "node", + "agentcore", + "project", + "create", + "--name", + "orders", + "--skip-install", + "--skip-git", + ]); + const projectRoot = join(process.cwd(), "orders"); + if (options.targets !== false) { + await writeFile( + join(projectRoot, "agentcore", "aws-targets.json"), + JSON.stringify([ + { name: "default", account: "111122223333", region: "us-east-1" }, + ...(options.staging ? [STAGING] : []), + ]), + ); + } + if (options.empty) { + // What `remove --all` leaves: the up-front signal the deploy asks about. + await writeFile( + join(projectRoot, "agentcore", "agentcore.json"), + JSON.stringify({ name: "orders", version: 1 }), + ); + } + process.chdir(projectRoot); + return projectRoot; +} + +describe("project build screen", () => { + test("starts at once, renders the backend's steps as the CLI does, then the CLI's own success line", async () => { + const { backend } = fakeBackend(); + const core = new TestCoreClient({ backends: { CDK: backend } }); + await inProject(core); + const r = renderScreen("/agentcore/project/build", { core }); + + // Both steps settle to ✓, as the inline TaskList leaves them on the + // command line; the finished steps' output tails collapse. + await waitForText(r.lastFrame, "✔ Built project 'orders'"); + const frame = r.lastFrame()!; + expect(frame).toContain("agentcore → project → build"); + // No frame — not even the first — advertised a question. + expect(r.frames.some((painted) => painted.includes("(y/N)") || painted.includes("y/n"))).toBe( + false, + ); + expect(frame).toContain("✓ Synthesizing CloudFormation templates"); + expect(frame).toContain("✓ Deploying stack"); + expect(frame).not.toContain("cdk synth"); + expect(frame).toContain("agentcore project deploy"); + + // Enter stays in the TUI: back to the project menu. + await r.press("return"); + await waitForText(r.lastFrame, "manage an AgentCore project"); + r.unmount(); + }); + + test("a failing step is marked ✕ with its output kept, above the error", async () => { + const { backend } = fakeBackend({ failure: new Error("synth exploded") }); + const core = new TestCoreClient({ backends: { CDK: backend } }); + await inProject(core); + const r = renderScreen("/agentcore/project/build", { core }); + + await waitForText(r.lastFrame, "✗ synth exploded"); + const frame = r.lastFrame()!; + expect(frame).toContain("✓ Synthesizing CloudFormation templates"); + expect(frame).toContain("✕ Deploying stack"); + expect(frame).toContain("CREATE_IN_PROGRESS | AWS::IAM::Role"); + // With no confirmation to return to, esc leaves for the project menu + // rather than running the build again. + await r.press("escape"); + await waitForText(r.lastFrame, "manage an AgentCore project"); + r.unmount(); + }); + + test("reports the CLI's own guidance outside a project", async () => { + const directory = await mkdtemp(join(tmpdir(), "agentcore-no-project-")); + tempDirectories.push(directory); + process.chdir(directory); + const r = renderScreen("/agentcore/project/build"); + + await waitForFlatText(r.lastFrame, "No AgentCore project found"); + expect(flatFrame(r.lastFrame)).toContain("agentcore project create"); + // esc is a way off the error, not just ctl+c. + await r.press("escape"); + await waitForText(r.lastFrame, "manage an AgentCore project"); + r.unmount(); + }); +}); + +describe("project deploy screen", () => { + test("one target: deploys to it at once, then the CLI's own success line", async () => { + const { backend, deploys } = fakeBackend(); + const core = new TestCoreClient({ backends: { CDK: backend } }); + await inProject(core); + const r = renderScreen("/agentcore/project/deploy", { core }); + + // A project with resources is not asked anything, as on the command line. + await waitForText(r.lastFrame, "✔ Deployed project 'orders' to target 'default'"); + const frame = flatFrame(r.lastFrame); + expect(frame).not.toContain("(y/N)"); + expect(frame).toContain("project orders"); + expect(frame).toContain("target default"); + expect(frame).toContain("✓ Synthesizing CloudFormation templates"); + expect(frame).toContain("✓ Deploying stack"); + // Stack outputs are not listed, as the command prints them only with --json. + expect(frame).not.toContain("RuntimeArn"); + expect(frame).toContain("[enter] go back"); + + // …and never confirms a teardown. + expect(deploys).toHaveLength(1); + expect(deploys[0]!.confirmed).toBe(false); + expect(deploys[0]!.input.target.name).toBe("default"); + r.unmount(); + }); + + test("a target-loading failure offers esc back and r to retry", async () => { + const { backend } = fakeBackend(); + const core = new TestCoreClient({ backends: { CDK: backend } }); + await inProject(core); + let attempts = 0; + const listTargets = core.projectManager.listTargets.bind(core.projectManager); + core.projectManager.listTargets = async (project) => { + attempts += 1; + if (attempts === 1) throw new Error("aws-targets.json is unreadable"); + return listTargets(project); + }; + const r = renderScreen("/agentcore/project/deploy", { core }); + + await waitForText(r.lastFrame, "✗ aws-targets.json is unreadable"); + expect(r.lastFrame()).toContain("[r] retry"); + expect(r.lastFrame()).toContain("[esc] back"); + + await r.write("r"); + await waitForText(r.lastFrame, "✔ Deployed project 'orders' to target 'default'"); + // Loading retries once, then deploy itself re-reads through listTargets. + expect(attempts).toBe(3); + r.unmount(); + }); + + test("esc leaves a target-loading failure for the project menu", async () => { + const core = new TestCoreClient({ backends: { CDK: fakeBackend().backend } }); + await inProject(core); + core.projectManager.listTargets = async () => { + throw new Error("aws-targets.json is unreadable"); + }; + const r = renderScreen("/agentcore/project/deploy", { core }); + + await waitForText(r.lastFrame, "✗ aws-targets.json is unreadable"); + await r.press("escape"); + await waitForText(r.lastFrame, "manage an AgentCore project"); + r.unmount(); + }); + + test("several targets: asks which, and deploys to the chosen one", async () => { + const { backend, deploys } = fakeBackend(); + const core = new TestCoreClient({ backends: { CDK: backend } }); + await inProject(core, { staging: true }); + const r = renderScreen("/agentcore/project/deploy", { core }); + + await waitForText(r.lastFrame, "choose a deployment target"); + const picker = flatFrame(r.lastFrame); + expect(picker).toContain("default 111122223333 us-east-1"); + expect(picker).toContain("staging 444455556666 eu-west-1"); + await r.press("down"); + await r.press("return"); + + await waitForText(r.lastFrame, "✔ Deployed project 'orders' to target 'staging'"); + expect(flatFrame(r.lastFrame)).toContain("target staging"); + expect(deploys[0]!.input.target).toEqual(STAGING); + r.unmount(); + }); + + test("revisiting reads targets afresh before starting another deploy", async () => { + const { backend, deploys } = fakeBackend(); + const core = new TestCoreClient({ backends: { CDK: backend } }); + const projectRoot = await inProject(core); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity, staleTime: 0 }, + }, + }); + const r = renderScreen("/agentcore/project/deploy", { core, queryClient }); + + await waitForText(r.lastFrame, "✔ Deployed project 'orders' to target 'default'"); + expect(deploys).toHaveLength(1); + await r.press("return"); + await waitForText(r.lastFrame, "manage an AgentCore project"); + + await writeFile( + join(projectRoot, "agentcore", "aws-targets.json"), + JSON.stringify([{ name: "default", account: "111122223333", region: "us-east-1" }, STAGING]), + ); + await r.write("deploy"); + await r.press("return"); + + await waitForText(r.lastFrame, "choose a deployment target"); + expect(deploys).toHaveLength(1); + r.unmount(); + }); + + test("revisiting resolves the project afresh before deciding whether to confirm teardown", async () => { + const { backend, deploys } = fakeBackend(); + const core = new TestCoreClient({ backends: { CDK: backend } }); + const projectRoot = await inProject(core); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity, staleTime: 0 }, + }, + }); + const r = renderScreen("/agentcore/project/deploy", { core, queryClient }); + + await waitForText(r.lastFrame, "✔ Deployed project 'orders' to target 'default'"); + expect(deploys).toHaveLength(1); + await r.press("return"); + await waitForText(r.lastFrame, "manage an AgentCore project"); + + await writeFile( + join(projectRoot, "agentcore", "agentcore.json"), + JSON.stringify({ name: "orders", version: 1 }), + ); + await r.write("deploy"); + await r.press("return"); + + await waitForFlatText(r.lastFrame, "declares no resources to deploy"); + expect(deploys).toHaveLength(1); + r.unmount(); + }); + + test("a fresh project with no aws-targets.json deploys, provisioning the default target as the CLI does", async () => { + const { backend, deploys } = fakeBackend(); + const core = new TestCoreClient({ + backends: { CDK: backend }, + resolveAccount: async () => "887863153624", + }); + await inProject(core, { targets: false }); + const r = renderScreen("/agentcore/project/deploy", { core }); + + await waitForText(r.lastFrame, "✔ Deployed project 'orders' to target 'default'"); + const frame = flatFrame(r.lastFrame); + // The manager's own provisioning step streams through like any other. + expect(frame).toContain("✓ Created default deployment target: account 887863153624"); + expect(deploys[0]!.input.target).toMatchObject({ name: "default", account: "887863153624" }); + r.unmount(); + }); + + test("an empty project asks the CLI's teardown question and confirms it on yes", async () => { + const { backend, deploys } = fakeBackend({ result: { outputs: {}, tornDown: true } }); + const core = new TestCoreClient({ backends: { CDK: backend } }); + await inProject(core, { empty: true }); + const r = renderScreen("/agentcore/project/deploy", { core }); + + await waitForFlatText(r.lastFrame, "declares no resources to deploy"); + // Confirm lays its (y/N) inline, so the question wraps around it. + expect(flatFrame(r.lastFrame)).toContain( + "deployed to target 'default' (111122223333/us-east-1). Continue?", + ); + await r.write("y"); + + await waitForText(r.lastFrame, "✔ Removed project 'orders' from target 'default'"); + expect(deploys[0]!.confirmed).toBe(true); + r.unmount(); + }); + + test("the outcome follows the result, not the preflight heuristic", async () => { + // The spec declares resources, so no teardown is asked — yet the backend + // reports it tore the stack down (nothing synthesized). The title must say + // what happened, as the command's own line does. + const { backend } = fakeBackend({ result: { outputs: {}, tornDown: true } }); + const core = new TestCoreClient({ backends: { CDK: backend } }); + await inProject(core); + const r = renderScreen("/agentcore/project/deploy", { core }); + + await waitForText(r.lastFrame, "✔ Removed project 'orders' from target 'default'"); + expect(r.lastFrame()).not.toContain("Deployed project"); + r.unmount(); + }); + + test("a failing deploy keeps the completed steps above the error", async () => { + const { backend } = fakeBackend({ failure: new Error("stack rolled back") }); + const core = new TestCoreClient({ backends: { CDK: backend } }); + await inProject(core); + const r = renderScreen("/agentcore/project/deploy", { core }); + + await waitForText(r.lastFrame, "✗ stack rolled back"); + expect(r.lastFrame()).toContain("✕ Deploying stack"); + r.unmount(); + }); +}); diff --git a/src/handlers/project/create/create.screen.test.tsx b/src/handlers/project/create/create.screen.test.tsx index c56259bed..5d939f28a 100644 --- a/src/handlers/project/create/create.screen.test.tsx +++ b/src/handlers/project/create/create.screen.test.tsx @@ -468,7 +468,7 @@ describe("project create wizard", () => { r.unmount(); }); - test("the spinner follows streamed progress without a blank row", async () => { + test("streamed progress renders as the CLI's step list", async () => { const core = new TestCoreClient(); let release!: () => void; const held = new Promise((resolve) => { @@ -494,22 +494,11 @@ describe("project create wizard", () => { await waitForText(r.lastFrame, "this project will be created"); await r.press("return"); - await waitFor(() => - r.frames.some( - (frame) => frame.includes("✓ syncing dependencies") && frame.includes("creating DemoApp…"), - ), - ); - const progressFrame = - [...r.frames] - .reverse() - .find( - (frame) => - frame.includes("✓ syncing dependencies") && frame.includes("creating DemoApp…"), - ) ?? ""; - const lines = progressFrame.split("\n"); - const eventLine = lines.findIndex((line) => line.includes("✓ syncing dependencies")); - const spinnerLine = lines.findIndex((line) => line.includes("creating DemoApp…")); - expect(spinnerLine).toBe(eventLine + 1); + // The running step is the spinner row itself, as on the command line; the + // generic "creating…" spinner shows only until the first step arrives. + await waitForText(r.lastFrame, "syncing dependencies"); + expect(r.lastFrame()).not.toContain("creating DemoApp…"); + expect(r.lastFrame()).not.toContain("✓ syncing dependencies"); r.unmount(); release(); diff --git a/src/handlers/project/create/screen.tsx b/src/handlers/project/create/screen.tsx index 35501dc3c..f3ff40bd7 100644 --- a/src/handlers/project/create/screen.tsx +++ b/src/handlers/project/create/screen.tsx @@ -18,7 +18,9 @@ import { FormRadioGroup, type FormRadioOption } from "../../../components/FormRa import { KeyValueTable } from "../../../components/KeyValueTable"; import { Stepper, type Step } from "../../../components/ui/stepper"; import { Spinner } from "../../../components/ui/spinner"; +import { TaskList, type Task } from "../../../components/ui/task-list"; import { Divider } from "../../../components/ui/divider"; +import { driveProgress } from "../../../tui/progress"; import { darkTheme } from "../../../components/ui/_core.js"; const theme = darkTheme; @@ -244,7 +246,7 @@ export function ProjectCreateScreen({ core }: ScreenProps) { const [values, setValues] = useState(emptyCreateProjectForm); const [stepIndex, setStepIndex] = useState(0); const [phase, setPhase] = useState({ kind: "form" }); - const [events, setEvents] = useState([]); + const [tasks, setTasks] = useState([]); // The step list is dynamic: the branch chosen on the type step decides // whether model or template (and, for strands, memory) questions follow. @@ -288,9 +290,7 @@ export function ProjectCreateScreen({ core }: ScreenProps) { } setPhase({ kind: "running" }); try { - for await (const event of core.projectManager.create(input)) { - if (event.type === "step") setEvents((current) => [...current, event.message]); - } + await driveProgress(core.projectManager.create(input), setTasks); setPhase({ kind: "success" }); } catch (error) { setPhase({ kind: "error", error: toError(error) }); @@ -322,8 +322,10 @@ export function ProjectCreateScreen({ core }: ScreenProps) { )} {phase.kind !== "form" && ( - - {phase.kind === "running" && } + + {phase.kind === "running" && tasks.length === 0 && ( + + )} {phase.kind === "success" && ( exit()} /> )} @@ -777,18 +779,6 @@ function ReviewStep({ // ─── result panels ──────────────────────────────────────────────────────────── -function EventLog({ events }: { events: string[] }) { - return ( - - {events.map((message, index) => ( - - ✓ {message} - - ))} - - ); -} - function SuccessPanel({ name, onContinue }: { name: string; onContinue: () => void }) { useInput((_input, key) => { if (key.return || key.escape) onContinue(); diff --git a/src/handlers/project/deploy/index.ts b/src/handlers/project/deploy/index.ts index 2e63fc08a..ab1af114b 100644 --- a/src/handlers/project/deploy/index.ts +++ b/src/handlers/project/deploy/index.ts @@ -15,6 +15,32 @@ type DeployProjectHandlerConfig = { io: AppIO; }; +/** The line both entry points print once a deploy finishes. */ +export function deployedMessage( + project: Project, + targetName: string, + result: DeployResult, +): string { + return result.tornDown + ? `Removed project '${project.name}' from target '${targetName}'` + : `Deployed project '${project.name}' to target '${targetName}'`; +} + +/** + * The teardown question both entry points ask. Asked before synthesis, so the + * target coordinates stand in for the stack name. + */ +export function teardownQuestion( + projectName: string, + target: { name: string; account: string; region: string }, +): string { + return ( + `Project '${projectName}' declares no resources to deploy. ` + + `Deploying will delete everything deployed to target ` + + `'${target.name}' (${target.account}/${target.region}). Continue?` + ); +} + export const createDeployProjectHandler = (config: DeployProjectHandlerConfig) => createHandler({ name: "deploy", @@ -72,9 +98,7 @@ export const createDeployProjectHandler = (config: DeployProjectHandlerConfig) = throw error; } - const message = result.tornDown - ? `Removed project '${project.name}' from target '${flags.target}'` - : `Deployed project '${project.name}' to target '${flags.target}'`; + const message = deployedMessage(project, flags.target, result); config.io.stderr.write(`${message}\n`); if (jsonOutput) { ctx.require(JsonRendererKey).renderJson({ message, ...result }); @@ -91,7 +115,7 @@ export const createDeployProjectHandler = (config: DeployProjectHandlerConfig) = * CDK app adds one), so the backend's count stays authoritative and this only * decides whether to ask the user before starting. */ -function declaresNothingDeployable(project: Project): boolean { +export function declaresNothingDeployable(project: Project): boolean { const { spec } = project; const collections = [ spec.runtimes, @@ -158,14 +182,8 @@ async function promptForTeardown( readline.once("SIGINT", cancel); readline.once("close", cancel); }); - // Asked before synthesis, so the exact stack name is not known yet; the - // target coordinates identify what would be deleted. const answer = await Promise.race([ - readline.question( - `Project '${projectName}' declares no resources to deploy.\n` + - `Deploying will delete everything deployed to target ` + - `'${target.name}' (${target.account}/${target.region}). Continue? (y/N) `, - ), + readline.question(`${teardownQuestion(projectName, target)} (y/N) `), cancelled, ]); return /^(?:y|yes)$/i.test(answer.trim()); diff --git a/src/handlers/project/deploy/screen.tsx b/src/handlers/project/deploy/screen.tsx new file mode 100644 index 000000000..8d3c55bcc --- /dev/null +++ b/src/handlers/project/deploy/screen.tsx @@ -0,0 +1,177 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "react-router"; +import { ConfirmAction } from "../../../components/ConfirmAction"; +import { Layout } from "../../../components/Layout"; +import { DataTable, type DataTableColumn } from "../../../components/ui/data-table"; +import { DEFAULT_TARGET_NAME, type AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; +import { ProjectKey, type Context } from "../../../router"; +import { RegionKey } from "../../keys"; +import type { ScreenProps } from "../../types"; +import { LoadingFrame, ProjectGate } from "../ProjectGate"; +import type { Project } from "../types"; +import { declaresNothingDeployable, deployedMessage, teardownQuestion } from "./index"; + +const BREADCRUMB = ["agentcore", "project", "deploy"]; +const DESCRIPTION = "deploy the project to AWS"; +const PROJECT_MENU = "/agentcore/project"; + +// DeployProjectScreen runs the same projectManager.deploy generator the command +// runs; ConfirmAction renders its steps through the same TaskList. With several +// targets it asks which first — the TUI's stand-in for --target. +export function DeployProjectScreen({ ctx, core }: ScreenProps) { + const navigate = useNavigate(); + return ( + navigate(PROJECT_MENU)} + > + {(project) => } + + ); +} + +type TargetRow = Record & AwsDeploymentTarget; + +const TARGET_COLUMNS = [ + { key: "name", header: "target", width: 16 }, + { key: "account", header: "account", width: 14 }, + { key: "region", header: "region", flex: true }, +] satisfies DataTableColumn[]; + +function DeployTarget({ + project, + ctx, + core, +}: { + project: Project; + ctx: Context; + core: ScreenProps["core"]; +}) { + const navigate = useNavigate(); + const [chosen, setChosen] = useState(); + + // A fresh project has no aws-targets.json yet: the list is empty and deploy + // provisions `default` on first run. + const targets = useQuery({ + queryKey: ["project-targets", project.rootPath], + queryFn: () => core.projectManager.listTargets(project), + gcTime: 0, + }); + + if (targets.data === undefined || targets.isFetching || targets.isError) { + return ( + navigate(PROJECT_MENU)} + /> + ); + } + + const declared = targets.data; + const targetName = + chosen ?? (declared.length <= 1 ? (declared[0]?.name ?? DEFAULT_TARGET_NAME) : undefined); + + if (targetName === undefined) { + return ( + + setChosen(row.name)} + onEscape={() => navigate(PROJECT_MENU)} + /> + + ); + } + + return ( + candidate.name === targetName)} + // With a choice behind us, esc returns to it; otherwise to the menu. + onCancel={() => (declared.length > 1 ? setChosen(undefined) : navigate(PROJECT_MENU))} + /> + ); +} + +function DeployConfirm({ + project, + ctx, + core, + targetName, + target, + onCancel, +}: { + project: Project; + ctx: Context; + core: ScreenProps["core"]; + targetName: string; + target: AwsDeploymentTarget | undefined; + onCancel: () => void; +}) { + const navigate = useNavigate(); + const region = ctx.require(RegionKey); + + // Confirmed only when the deploy would tear the stack down, the one case the + // command asks. Nothing may block on input once the progress UI is up, so the + // answer is the pre-answered decision the backend consults; if its own count + // disagrees with this preflight it reports the "re-run with --yes" error. + const teardown = target !== undefined && declaresNothingDeployable(project); + + return ( + teardown, + }); + // The title follows the result, not the preflight heuristic, which + // synthesis can disagree with. Outputs are not listed: the command + // prints them only with --json. + return { title: deployedMessage(project, targetName, result), rows: {} }; + }} + successTitle="Deploy finished" + runningLabel="deploying…" + onDone={() => navigate(PROJECT_MENU)} + doneLabel="go back" + onCancel={onCancel} + /> + ); +} diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx index 00ba1386a..07a509876 100644 --- a/src/handlers/project/invoke/screen.tsx +++ b/src/handlers/project/invoke/screen.tsx @@ -10,7 +10,8 @@ import { HarnessChat } from "../../harness/invoke/screen"; import { RegionKey } from "../../keys"; import { RuntimeInvokeConsole } from "../../runtime/invoke/screen"; import type { ScreenProps } from "../../types"; -import type { Project, ResolvedDeployedResources } from "../types"; +import type { ResolvedDeployedResources } from "../types"; +import { useProject } from "../ProjectGate"; type ProjectInvokableRow = Record & { resourceType: "runtime" | "harness"; @@ -34,36 +35,13 @@ type Destination = export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) { const navigate = useNavigate(); - const [project, setProject] = useState(() => ctx.value(ProjectKey)); + // The project comes from the launch context when a project command opened + // the TUI, and is resolved from the cwd otherwise. + const { data: project, error: projectError } = useProject(core, ctx.value(ProjectKey)); const [deployed, setDeployed] = useState(); const [destination, setDestination] = useState(); - const [error, setError] = useState(); - - useEffect(() => { - if (project) return; - let active = true; - const from = process.cwd(); - void core.projectManager - .resolve({ filePath: from }) - .then((resolved) => { - if (!active) return; - if (!resolved) { - setError( - `No AgentCore project found at ${from} or any parent directory ` + - `(looked for agentcore/agentcore.json). ` + - `Run 'agentcore project create' to scaffold one.`, - ); - return; - } - setProject(resolved); - }) - .catch((cause: unknown) => { - if (active) setError(cause instanceof Error ? cause.message : String(cause)); - }); - return () => { - active = false; - }; - }, [core.projectManager, project]); + const [deployedError, setDeployedError] = useState(); + const error = projectError?.message ?? deployedError; useEffect(() => { if (!project) return; @@ -74,7 +52,7 @@ export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) { if (active) setDeployed(resolved); }) .catch((cause: unknown) => { - if (active) setError(cause instanceof Error ? cause.message : String(cause)); + if (active) setDeployedError(cause instanceof Error ? cause.message : String(cause)); }); return () => { active = false; diff --git a/src/handlers/project/project.screen.test.tsx b/src/handlers/project/project.screen.test.tsx index 846c6d3ea..03ad42de8 100644 --- a/src/handlers/project/project.screen.test.tsx +++ b/src/handlers/project/project.screen.test.tsx @@ -76,9 +76,10 @@ describe("project subcommands without a screen", () => { // Reading the cases off the router also guards Root's hand-written // PROJECT_COMMANDS: an unrouted subcommand hits the catch-all, which resolves // instead of rejecting. Frames can't detect that — the catch-all exits before - // painting, so it and this screen both render empty. `create` and `invoke` - // are excluded because both have real screens. - test.each(projectSubcommands().filter((command) => command !== "create" && command !== "invoke"))( + // painting, so it and this screen both render empty. Subcommands with a real + // screen are excluded. + const WITH_SCREENS = ["create", "invoke", "build", "deploy"]; + test.each(projectSubcommands().filter((command) => !WITH_SCREENS.includes(command)))( "%s tears down the TUI with NotImplementedError", async (command) => { const { streams } = ttyTestIO(); @@ -99,7 +100,7 @@ describe("project subcommands without a screen", () => { const { streams } = ttyTestIO(); const caught: unknown = await renderTuiAt( - "/agentcore/project/deploy", + "/agentcore/project/status", ValueContext.EmptyContext(), new TestCoreClient(), streams.io, @@ -110,7 +111,7 @@ describe("project subcommands without a screen", () => { expect(caught).toBeInstanceOf(NotImplementedError); const error = caught as NotImplementedError; - expect(error.message).toContain("agentcore project deploy --help"); + expect(error.message).toContain("agentcore project status --help"); // Surfaces as a plain CLI failure, not a crash. expect(error.exitCode).toBe(1); }); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index dc2dd279d..93b5a775e 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -349,6 +349,12 @@ export interface ProjectManager { /** Deploy the project to one of its configured AWS targets. */ deploy(project: Project, input: DeployProjectInput): AsyncGenerator; + /** + * The targets aws-targets.json declares, in file order; empty when the file + * is absent (deploy then synthesizes the default target on demand). + */ + listTargets(project: Project): Promise; + /** * Look up a target in aws-targets.json without provisioning or requiring it. * Returns undefined when the file or the named entry is absent — unlike diff --git a/src/middleware/withProject.tsx b/src/middleware/withProject.tsx index 95d1e1b6b..f2166151f 100644 --- a/src/middleware/withProject.tsx +++ b/src/middleware/withProject.tsx @@ -15,6 +15,15 @@ interface WithProjectConfig { * * @param config - Contains the {@link ProjectManager} and an optional `cwd` to search from. */ +/** The guidance printed when no project encloses `from`; TUI screens reuse it. */ +export function projectNotFoundMessage(from: string): string { + return ( + `No AgentCore project found at ${from} or any parent directory ` + + `(looked for agentcore/agentcore.json). ` + + `Run 'agentcore project create' to scaffold one.` + ); +} + export function withProject(config: WithProjectConfig): Middleware { return (h) => ({ name: () => h.name(), @@ -29,11 +38,7 @@ export function withProject(config: WithProjectConfig): Middleware { const from = config.cwd ?? process.cwd(); const project = await config.projectManager.resolve({ filePath: from }); if (!project) { - throw new ProjectStateError( - `No AgentCore project found at ${from} or any parent directory ` + - `(looked for agentcore/agentcore.json). ` + - `Run 'agentcore project create' to scaffold one.`, - ); + throw new ProjectStateError(projectNotFoundMessage(from)); } await h.handle(ctx.withValue(ProjectKey, project), flags, args); }, diff --git a/src/testing/index.tsx b/src/testing/index.tsx index d06178516..65dbbab2f 100644 --- a/src/testing/index.tsx +++ b/src/testing/index.tsx @@ -25,6 +25,8 @@ export { cleanupScreens, keys, waitForText, + flatFrame, + waitForFlatText, type RenderScreenOptions, type RenderScreenResult, } from "./renderScreen"; diff --git a/src/testing/renderScreen.tsx b/src/testing/renderScreen.tsx index 1cc7ae47a..84eee7d59 100644 --- a/src/testing/renderScreen.tsx +++ b/src/testing/renderScreen.tsx @@ -170,3 +170,19 @@ export function waitForText( ): Promise { return waitFor(() => (lastFrame() ?? "").includes(text), timeoutMs); } + +// flatFrame collapses a frame's whitespace so text that Ink lays out across +// columns or lines (a key/value table, a wrapped sentence) can be matched as a +// single string. +export function flatFrame(lastFrame: () => string | undefined): string { + return (lastFrame() ?? "").replace(/\s+/g, " "); +} + +// waitForFlatText is waitForText against the flattened frame. +export function waitForFlatText( + lastFrame: () => string | undefined, + text: string, + timeoutMs = 1000, +): Promise { + return waitFor(() => flatFrame(lastFrame).includes(text), timeoutMs); +} diff --git a/src/tui/progress.test.tsx b/src/tui/progress.test.tsx index 0365b33a0..68e8b87d4 100644 --- a/src/tui/progress.test.tsx +++ b/src/tui/progress.test.tsx @@ -1,6 +1,12 @@ import { describe, expect, test } from "bun:test"; import { testIO } from "../testing"; -import { runWithProgress, type ProgressEvent } from "./progress"; +import { + applyProgressEvent, + driveProgress, + runWithProgress, + settleProgress, + type ProgressEvent, +} from "./progress"; // Ink writes cursor/erase sequences around each frame; the assertions here // care about frame text, not terminal control. Built without a control-char @@ -135,3 +141,73 @@ describe("runWithProgress interactive path", () => { expect(stripAnsi(io.stderr())).toContain("✓ Only step"); }); }); + +describe("applyProgressEvent / settleProgress", () => { + test("a step completes the running task and starts the next", () => { + let tasks = applyProgressEvent([], { type: "step", message: "synth" }); + expect(tasks).toEqual([{ title: "synth", state: "running", tail: [] }]); + + tasks = applyProgressEvent(tasks, { type: "output", line: "one" }); + tasks = applyProgressEvent(tasks, { type: "step", message: "deploy" }); + expect(tasks).toEqual([ + { title: "synth", state: "done", tail: [] }, + { title: "deploy", state: "running", tail: [] }, + ]); + }); + + test("output joins the running task's tail, bounded by tailLines", () => { + let tasks = applyProgressEvent([], { type: "step", message: "deploy" }); + for (const line of ["a", "b", "c"]) { + tasks = applyProgressEvent(tasks, { type: "output", line }, 2); + } + expect(tasks[0]!.tail).toEqual(["b", "c"]); + }); + + test("output before any step is dropped", () => { + expect(applyProgressEvent([], { type: "output", line: "stray" })).toEqual([]); + }); + + test("settling keeps the tail on failure and clears it on success", () => { + let tasks = applyProgressEvent([], { type: "step", message: "deploy" }); + tasks = applyProgressEvent(tasks, { type: "output", line: "boom" }); + + expect(settleProgress(tasks, "failed")).toEqual([ + { title: "deploy", state: "failed", tail: ["boom"] }, + ]); + expect(settleProgress(tasks, "done")).toEqual([{ title: "deploy", state: "done", tail: [] }]); + expect(settleProgress([], "done")).toEqual([]); + }); +}); + +describe("driveProgress", () => { + test("reports the task list after each event, settles done, and resolves the return value", async () => { + async function* work() { + yield { type: "step", message: "one" } as ProgressEvent; + yield { type: "output", line: "detail" } as ProgressEvent; + return 42; + } + const frames: string[] = []; + const result = await driveProgress(work(), (tasks) => + frames.push( + tasks.map((task) => `${task.state}:${task.title}:${task.tail.join(",")}`).join("|"), + ), + ); + expect(result).toBe(42); + expect(frames).toEqual(["running:one:", "running:one:detail", "done:one:"]); + }); + + test("settles the running task failed and rethrows unchanged", async () => { + const failure = new Error("boom"); + async function* work() { + yield { type: "step", message: "one" } as ProgressEvent; + throw failure; + } + let last: string | undefined; + await expect( + driveProgress(work(), (tasks) => { + last = tasks.map((task) => `${task.state}:${task.title}`).join("|"); + }), + ).rejects.toBe(failure); + expect(last).toBe("failed:one"); + }); +}); diff --git a/src/tui/progress.tsx b/src/tui/progress.tsx index af1c327b1..6de58faf4 100644 --- a/src/tui/progress.tsx +++ b/src/tui/progress.tsx @@ -24,6 +24,68 @@ export type RunWithProgressOptions = { const DEFAULT_TAIL_LINES = 5; +/** + * Folds one progress event into a task list: a `step` completes the running + * task and starts a new one; an `output` line joins the running task's tail. + */ +export function applyProgressEvent( + tasks: readonly Task[], + event: ProgressEvent, + tailLines = DEFAULT_TAIL_LINES, +): Task[] { + const current = tasks[tasks.length - 1]; + if (event.type === "step") { + const settled = current + ? [...tasks.slice(0, -1), { ...current, state: "done" as const, tail: [] }] + : []; + return [...settled, { title: event.message, state: "running", tail: [] }]; + } + // An output line before the first step has nowhere to render; the debug log + // still has it. + if (!current) return [...tasks]; + return [ + ...tasks.slice(0, -1), + { ...current, tail: [...current.tail, event.line].slice(-tailLines) }, + ]; +} + +/** + * Marks the running task finished: `done` when the generator returned (its + * tail collapses), `failed` when it threw (the tail stays, so the last output + * is visible above the error). + */ +export function settleProgress(tasks: readonly Task[], state: "done" | "failed"): Task[] { + const current = tasks[tasks.length - 1]; + if (!current) return [...tasks]; + return [...tasks.slice(0, -1), { ...current, state, tail: state === "done" ? [] : current.tail }]; +} + +/** + * Drains a progress generator, reporting the task list after every change, and + * resolves with its return value. On failure the running task is marked failed + * and the error rethrown unchanged. Renderers supply only how to draw the tasks. + */ +export async function driveProgress( + generator: AsyncGenerator, + onChange: (tasks: Task[]) => void, + tailLines = DEFAULT_TAIL_LINES, +): Promise { + let tasks: Task[] = []; + try { + let next = await generator.next(); + while (!next.done) { + tasks = applyProgressEvent(tasks, next.value, tailLines); + onChange(tasks); + next = await generator.next(); + } + onChange(settleProgress(tasks, "done")); + return next.value; + } catch (error) { + onChange(settleProgress(tasks, "failed")); + throw error; + } +} + /** * Drains a progress generator into a live step list and resolves with the * generator's return value. @@ -57,7 +119,6 @@ export async function runWithProgress( } const tailLines = options.tailLines ?? DEFAULT_TAIL_LINES; - const tasks: Task[] = []; // Ink renders onto its `stdout` option; handing it io.stderr keeps progress // off the machine-readable stream, same as the plain path. const instance = render(, { @@ -70,43 +131,15 @@ export async function runWithProgress( exitOnCtrlC: false, patchConsole: false, }); - const draw = () => instance.rerender(); - const current = () => tasks[tasks.length - 1]; + // A failed step keeps its tail: the last frame stays in scrollback above the + // error runWithExitCode prints after the rethrow. try { - let next = await generator.next(); - while (!next.done) { - const event = next.value; - if (event.type === "step") { - const previous = current(); - if (previous) { - previous.state = "done"; - previous.tail = []; - } - tasks.push({ title: event.message, state: "running", tail: [] }); - } else { - // An output line before the first step has nowhere to render; the - // debug log still has it. - const task = current(); - if (task) task.tail = [...task.tail, event.line].slice(-tailLines); - } - draw(); - next = await generator.next(); - } - const last = current(); - if (last) { - last.state = "done"; - last.tail = []; - } - draw(); - return next.value; - } catch (error) { - // The failed step keeps its tail: the last frame stays in scrollback above - // the error message runWithExitCode prints after the rethrow. - const task = current(); - if (task) task.state = "failed"; - draw(); - throw error; + return await driveProgress( + generator, + (tasks) => instance.rerender(), + tailLines, + ); } finally { instance.unmount(); await instance.waitUntilExit();