diff --git a/README.md b/README.md index 53f426695..eabb69dbc 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,9 @@ responses. `agentcore` wraps all of that behind one ergonomic tool. Commands with operation flags run headlessly. Bare Harness, Runtime, Memory, Identity, and Gateway branches and leaves open their interactive flows, as does a bare `project create` in a terminal (any flag, `--json`, or a non-TTY stays -headless). +headless). A bare `project status` opens a Linked Resources view that groups +the project's resources by agent and forwards to each deployed resource's +detail page. ``` agentcore # interactive TUI @@ -133,7 +135,7 @@ agentcore # interactive TUI │ ├── invoke # invoke a deployed project resource │ │ ├── runtime # use the existing Runtime invoke experience │ │ └── harness # use the existing Harness invoke experience -│ ├── status # inspect deployed project resources +│ ├── status # inspect deployed project resources (TUI when run bare) │ └── build # synthesize the project's CloudFormation templates └── config # read/write global config values ``` diff --git a/src/components/Root.tsx b/src/components/Root.tsx index d44b90b0c..a24ecea0f 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -114,6 +114,7 @@ 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 { ProjectStatusScreen } from "../handlers/project/status/screen.tsx"; import { HelpScreen, RootScreen } from "../handlers/screen.tsx"; import type { Context } from "../router"; @@ -772,6 +773,10 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/project/create" element={} /> + } + /> {/* Every known command without a screen of its own: a group opens its menu and a leaf its interactive help. Unknown routes retain the help-and-exit fallback. */} diff --git a/src/components/ui/tree-view/TreeView.tsx b/src/components/ui/tree-view/TreeView.tsx index 9a0415854..23818afa5 100644 --- a/src/components/ui/tree-view/TreeView.tsx +++ b/src/components/ui/tree-view/TreeView.tsx @@ -6,6 +6,8 @@ import type { InkUITheme } from "../_core.js"; export interface TreeNode { id: string; label: string; + /** Muted text rendered after the label (e.g. a status or description). */ + annotation?: string; children?: TreeNode[]; icon?: string; defaultExpanded?: boolean; @@ -23,6 +25,11 @@ export interface TreeViewProps { leafIcon?: string; branchIcon?: string; branchOpenIcon?: string; + /** + * Mark the focused row with a "❯ " prefix in the focus color instead of + * inverse video, matching the selection style of DataTable and the menus. + */ + focusMarker?: boolean; focus?: boolean; theme?: InkUITheme; } @@ -62,6 +69,7 @@ export function TreeView({ leafIcon = "📄", branchIcon = "📁", branchOpenIcon = "📂", + focusMarker = false, focus = true, theme = darkTheme, }: TreeViewProps): React.ReactElement { @@ -149,6 +157,7 @@ export function TreeView({ return ( + {focusMarker && {isFocused ? "❯ " : " "}} {guides && depth > 0 && {guidePrefix}} ({ } bold={isFocused} dimColor={node.disabled} - inverse={isFocused} + inverse={isFocused && !focusMarker} > {branchChar} @@ -168,6 +177,12 @@ export function TreeView({ {showIcons ? ` ${icon} ` : " "} {node.label} + {node.annotation !== undefined && ( + + {" "} + {node.annotation} + + )} ); })} diff --git a/src/handlers/gateway/get/screen.tsx b/src/handlers/gateway/get/screen.tsx index 1a456f462..db9fdf3e1 100644 --- a/src/handlers/gateway/get/screen.tsx +++ b/src/handlers/gateway/get/screen.tsx @@ -3,10 +3,10 @@ import { useNavigate, useParams } from "react-router"; import { JsonDetail } from "../../../components/JsonDetail"; import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen"; import type { ScreenProps } from "../../types"; -import { coreOptsFromCtx } from "../../utils"; +import { useCoreOpts } from "../../utils"; function useGatewayDetail({ ctx, core }: ScreenProps, gatewayId: string | undefined) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); return useQuery({ queryKey: ["gateway", opts.region, gatewayId], queryFn: () => core.gateway.getGateway(gatewayId!, opts), diff --git a/src/handlers/gateway/target/get/screen.tsx b/src/handlers/gateway/target/get/screen.tsx index 51b4fc764..f6c95e382 100644 --- a/src/handlers/gateway/target/get/screen.tsx +++ b/src/handlers/gateway/target/get/screen.tsx @@ -2,11 +2,11 @@ import { useQuery } from "@tanstack/react-query"; import { useParams } from "react-router"; import { JsonDetail } from "../../../../components/JsonDetail"; import type { ScreenProps } from "../../../types"; -import { coreOptsFromCtx } from "../../../utils"; +import { useCoreOpts } from "../../../utils"; export function GatewayTargetGetScreen(props: ScreenProps) { const { gatewayId, targetId } = useParams(); - const opts = coreOptsFromCtx(props.ctx); + const opts = useCoreOpts(props.ctx); const detail = useQuery({ queryKey: ["gateway-target", opts.region, gatewayId, targetId], queryFn: () => props.core.gateway.getGatewayTarget(gatewayId!, targetId!, opts), diff --git a/src/handlers/harness/get/screen.tsx b/src/handlers/harness/get/screen.tsx index 2f86f1c2f..21da15693 100644 --- a/src/handlers/harness/get/screen.tsx +++ b/src/handlers/harness/get/screen.tsx @@ -1,7 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { useNavigate, useParams } from "react-router"; import type { ScreenProps } from "../../types"; -import { coreOptsFromCtx } from "../../utils"; +import { useCoreOpts } from "../../utils"; import { JsonDetail } from "../../../components/JsonDetail"; import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen"; @@ -45,7 +45,7 @@ const ACTIONS: { name: string; description: string; to: (id: string) => string } // harness's flows (detail JSON, endpoints, versions, invoke, exec). The harness // ID comes from the `:harnessId` route path value. function useHarnessDetail({ ctx, core }: ScreenProps, harnessId: string | undefined) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); return useQuery({ queryKey: ["harness", opts.region, harnessId], queryFn: () => core.harness.getHarness(harnessId!, opts), diff --git a/src/handlers/memory/get/screen.tsx b/src/handlers/memory/get/screen.tsx index 1904fde3e..f37342403 100644 --- a/src/handlers/memory/get/screen.tsx +++ b/src/handlers/memory/get/screen.tsx @@ -3,7 +3,7 @@ import { useNavigate, useParams } from "react-router"; import { JsonDetail } from "../../../components/JsonDetail"; import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen"; import type { ScreenProps } from "../../types"; -import { coreOptsFromCtx } from "../../utils"; +import { useCoreOpts } from "../../utils"; const ACTIONS = [ { @@ -34,7 +34,7 @@ const ACTIONS = [ ] as const; function useMemoryDetail({ ctx, core }: ScreenProps, memoryId: string | undefined) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); return useQuery({ queryKey: ["memory", opts.region, memoryId, "full"], queryFn: () => core.memory.getMemory(memoryId!, "full", opts), diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 0d4b22794..e97707a05 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -35,6 +35,7 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router "invoke", "build", "deploy", + "status", ); // Without a default, a bare `agentcore project` falls back to Commander's help @@ -99,11 +100,26 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router ), ); project.handler(createProjectInvokeHandler(core, io)); - project.handler( - withProject({ projectManager: config.projectManager })( - createStatusProjectHandler({ projectManager: config.projectManager }), - ), - ); + // A bare `agentcore project status` in an interactive session opens the TUI + // linked-resources screen; any user-supplied flag, --json, or a non-TTY + // invocation keeps the headless JSON report (same dispatch shape as create). + // withProject stays outermost so the not-found guidance outside a project is + // the CLI's own, and the resolved project seeds the screen via ProjectKey. + const statusProject = createStatusProjectHandler({ projectManager: config.projectManager }); + const statusProjectWithTui = withTuiOnEmptyFlagsAndArgs(core, io)(statusProject); + const statusProjectDispatch: Handler = { + name: () => statusProject.name(), + description: () => statusProject.description(), + flags: () => statusProject.flags(), + arguments: () => statusProject.arguments(), + doesSupportTui: () => statusProject.doesSupportTui(), + children: () => statusProject.children(), + handle: (ctx, flags, args) => + isInteractive() + ? statusProjectWithTui.handle(ctx, flags, args) + : statusProject.handle(ctx, flags, args), + }; + project.handler(withProject({ projectManager: config.projectManager })(statusProjectDispatch)); // withProject wraps only the commands that require an existing project, so // `create` (which refuses to nest inside one) stays unaffected. project.handler( diff --git a/src/handlers/project/project.screen.test.tsx b/src/handlers/project/project.screen.test.tsx index e088de6d4..7999be300 100644 --- a/src/handlers/project/project.screen.test.tsx +++ b/src/handlers/project/project.screen.test.tsx @@ -89,7 +89,7 @@ describe("project menu: command-line-only subcommands", () => { const r = renderScreen("/agentcore/project"); await waitForText(r.lastFrame, "command line only"); - const withScreens = ["create", "deploy", "invoke", "build"]; + const withScreens = ["create", "deploy", "invoke", "build", "status"]; const { screens, cliOnly } = menuEntries(r.lastFrame()!); expect(screens.toSorted()).toEqual(withScreens.toSorted()); expect(cliOnly.toSorted()).toEqual( diff --git a/src/handlers/project/status/index.test.ts b/src/handlers/project/status/index.test.ts index 450cd4fb2..c91ccccb7 100644 --- a/src/handlers/project/status/index.test.ts +++ b/src/handlers/project/status/index.test.ts @@ -8,6 +8,8 @@ import { TestCoreClient, TestGlobalConfigAccessor, testIO, + ttyTestIO, + waitFor, } from "../../../testing"; import type { ProjectBackend } from "../../../core/project"; import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; @@ -45,8 +47,7 @@ function fakeBackend(deployed: ResolvedProjectResource[]) { return { targets, backend }; } -function testStatusCommand(deployed: ResolvedProjectResource[] = []) { - const io = testIO(); +function testStatusCommand(deployed: ResolvedProjectResource[] = [], io = testIO()) { const fake = fakeBackend(deployed); const root = createRootHandler(new TestCoreClient({ backends: { CDK: fake.backend } }), { io: io.io, @@ -232,3 +233,61 @@ describe("project status handler", () => { ); }); }); + +describe("project status dispatch", () => { + // The bare-invocation tests above run without a TTY and assert the exact + // JSON envelope, which pins the non-TTY headless path; these cover how a TTY + // changes (and does not change) the dispatch. + test("bare status in a TTY session opens the TUI instead of printing JSON", async () => { + const tty = ttyTestIO(); + const subject = testStatusCommand([HARNESS_ROW], tty.streams); + await inProject(subject); + + // outcome never rejects, so a mid-pump failure cannot trip bun's + // unhandled-rejection detection before the final assertion. + const outcome = subject.run().then( + () => ({ ok: true as const }), + (error: unknown) => ({ ok: false as const, error }), + ); + let settled = false; + void outcome.finally(() => { + settled = true; + }); + + // The screen never finishes on its own; Ctrl+C (re-sent until the app + // reacts) closes it and resolves the route cleanly. + await waitFor( + () => { + if (!settled) tty.stdin.write("\x03"); + return settled; + }, + 5000, + 150, + ); + expect(await outcome).toEqual({ ok: true }); + expect(subject.io.stdout()).not.toContain('"projectName"'); + }, 10000); + + test("an explicitly passed --target stays headless even in a TTY", async () => { + const subject = testStatusCommand([HARNESS_ROW], ttyTestIO().streams); + await inProject(subject); + + await subject.run(["--target", "default"]); + + expect(subject.json()).toMatchObject({ projectName: "orders", target: "default" }); + }); + + test("--json stays headless even in a TTY", async () => { + const subject = testStatusCommand([HARNESS_ROW], ttyTestIO().streams); + await inProject(subject); + + await subject.run(["--json"]); + + expect(subject.json()).toEqual({ + projectName: "orders", + target: "default", + region: "us-east-1", + resources: [HARNESS_ROW], + }); + }); +}); diff --git a/src/handlers/project/status/screen.tsx b/src/handlers/project/status/screen.tsx new file mode 100644 index 000000000..dd118184a --- /dev/null +++ b/src/handlers/project/status/screen.tsx @@ -0,0 +1,281 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Box, Text, useInput } from "ink"; +import { useNavigate } from "react-router"; +import { Layout } from "../../../components/Layout"; +import { TreeView, type TreeNode } from "../../../components/ui/tree-view"; +import { darkTheme } from "../../../components/ui/_core.js"; +import { ProjectKey } from "../../../router"; +import type { ScreenProps } from "../../types"; +import type { DeployableResource, Project, ResolvedProjectResource } from "../types"; +import { LoadingFrame, ProjectGate } from "../ProjectGate"; + +const theme = darkTheme; + +const BREADCRUMB = ["agentcore", "project", "status"]; +const PROJECT_MENU = "/agentcore/project"; +// The TUI opens only from a bare invocation (an explicit --target keeps the +// headless report), so the screen always shows the default target, like the +// invoke picker. +const TARGET_NAME = "default"; + +const RESOURCE_TYPE_LABELS: Record = { + runtime: "Runtime", + harness: "Harness", + memory: "Memory", + "knowledge-base": "Knowledge Base", + credential: "Credential", + evaluator: "Evaluator", + "online-eval": "Online Eval", + gateway: "Gateway", + "gateway-target": "Gateway Target", + "policy-engine": "Policy Engine", + policy: "Policy", + "config-bundle": "Config Bundle", + "payment-manager": "Payment Manager", + "payment-connector": "Payment Connector", +}; + +// The detail routes a deployed resource can forward to. Types without a detail +// screen are listed but not navigable. +const DETAIL_ROUTES: Partial string>> = { + runtime: (id) => `/agentcore/runtime/get/${encodeURIComponent(id)}`, + harness: (id) => `/agentcore/harness/get/${encodeURIComponent(id)}`, + memory: (id) => `/agentcore/memory/get/${encodeURIComponent(id)}`, + gateway: (id) => `/agentcore/gateway/get/${encodeURIComponent(id)}`, +}; + +// resolveProjectResources reports most ids as ARNs (e.g. +// arn:aws:bedrock-agentcore:::memory/) while the +// detail routes and Core clients take the bare service id, so the id is the +// resource path after the type. Ids that are not ARNs (a gateway target's id, +// for one) pass through unchanged. +function serviceIdFromArn(id: string): string { + const match = /^arn:[^:]*:[^:]*:[^:]*:[^:]*:[^/]+\/(.+)$/.exec(id); + return match?.[1] ?? id; +} + +type ResourceNodeData = { + // route is where enter forwards; unset rows show `hint` instead. + route?: string; + hint?: string; +}; + +type StatusNode = TreeNode; + +// routeFor resolves the detail route for a deployed resource. Gateway targets +// have a detail screen too, but its route needs the owning gateway's id, which +// only the parent row carries. +function routeFor( + resource: ResolvedProjectResource, + parent?: ResolvedProjectResource, +): string | undefined { + if (resource.deploymentState !== "deployed") return undefined; + if (resource.resourceType === "gateway-target") { + if (parent?.deploymentState !== "deployed") return undefined; + const gatewayId = encodeURIComponent(serviceIdFromArn(parent.id)); + return `/agentcore/gateway/target/get/${gatewayId}/${encodeURIComponent(resource.id)}`; + } + return DETAIL_ROUTES[resource.resourceType]?.(serviceIdFromArn(resource.id)); +} + +// buildStatusNodes groups the resolved resources by agent. Each entry in +// spec.runtimes (code agents) and spec.harnesses (managed harness agents) is a +// top-level group holding the agent's own deployed resource; everything not +// attributable to an agent lands in a shared "project" group so nothing the +// project declares is dropped. +// +// Memories group under runtime agents: the CDK L3 injects a MEMORY__ID +// env var for every declared memory into every runtime (see +// src/core/project/templates/runtime.ts), so each declared memory is reachable +// from each runtime agent. A harness's memory binding lives in its own +// harness.json (HarnessMemoryRefSchema), not in the project spec this report is +// built from — a managed one is provisioned inside the harness and never +// appears here — so harness groups list just the harness itself and memories a +// harness may reference by name stay visible under the project group. +export function buildStatusNodes( + spec: Project["spec"], + resources: ResolvedProjectResource[], +): StatusNode[] { + const byKey = new Map(resources.map((r) => [`${r.resourceType}:${r.name}`, r])); + const claimed = new Set(); + const claim = (resourceType: DeployableResource, name: string) => { + const resource = byKey.get(`${resourceType}:${name}`); + if (resource) claimed.add(`${resourceType}:${name}`); + return resource; + }; + + // The type column width, minus the guide characters each extra depth adds, + // keeps names aligned across the tree. + const typeWidth = + Math.max(...resources.map((r) => RESOURCE_TYPE_LABELS[r.resourceType].length), 0) + 2; + + const resourceNode = ( + resource: ResolvedProjectResource, + parentId: string, + depth: number, + parent?: ResolvedProjectResource, + ): StatusNode => { + const id = `${parentId}/${resource.resourceType}:${resource.name}`; + const type = RESOURCE_TYPE_LABELS[resource.resourceType]; + const route = routeFor(resource, parent); + const deployed = resource.deploymentState === "deployed"; + return { + id, + label: `${type.padEnd(Math.max(typeWidth - 2 * (depth - 1), type.length + 1))}${resource.name}`, + annotation: deployed ? "deployed" : "local-only", + // A declared-but-undeployed resource has nothing to fetch, so its row + // cannot be selected; deployed types without a detail screen stay + // selectable and explain themselves instead. + disabled: !deployed, + defaultExpanded: true, + children: resource.children?.map((child) => resourceNode(child, id, depth + 1, resource)), + data: route ? { route } : { hint: `${type} ${resource.name} has no detail view.` }, + }; + }; + + const agentGroups: StatusNode[] = [ + ...spec.runtimes.map(({ name }): StatusNode => { + const id = `agent:${name}`; + const children = [ + claim("runtime", name), + ...spec.memories.map(({ name: memoryName }) => claim("memory", memoryName)), + ].filter((resource) => resource !== undefined); + return { + id, + label: name, + annotation: "agent", + defaultExpanded: true, + children: children.map((resource) => resourceNode(resource, id, 1)), + }; + }), + ...spec.harnesses.map(({ name }): StatusNode => { + const id = `agent:${name}`; + const children = [claim("harness", name)].filter((resource) => resource !== undefined); + return { + id, + label: name, + annotation: "agent", + defaultExpanded: true, + children: children.map((resource) => resourceNode(resource, id, 1)), + }; + }), + ]; + + const shared = resources.filter((r) => !claimed.has(`${r.resourceType}:${r.name}`)); + const sharedGroup: StatusNode[] = shared.length + ? [ + { + id: "project", + label: "project", + annotation: "shared resources", + defaultExpanded: true, + children: shared.map((resource) => resourceNode(resource, "project", 1)), + }, + ] + : []; + + return [...agentGroups, ...sharedGroup]; +} + +// The project comes from the launch context when a project command opened the +// TUI, and is resolved from the cwd otherwise — the gate reports the CLI's own +// not-found guidance when there is none. +export function ProjectStatusScreen({ ctx, core }: ScreenProps) { + const navigate = useNavigate(); + return ( + navigate(PROJECT_MENU)} + > + {(project) => } + + ); +} + +function ProjectStatusView({ + core, + project, +}: Pick & { + project: Project; +}) { + const navigate = useNavigate(); + const [hint, setHint] = useState(); + + const status = useQuery({ + queryKey: ["project-status", project.rootPath, TARGET_NAME], + queryFn: () => core.projectManager.resolveProjectResources(project, { target: TARGET_NAME }), + }); + + const nodes = useMemo( + () => (status.data ? buildStatusNodes(project.spec, status.data.resources) : []), + [project, status.data], + ); + + const goBack = () => navigate(PROJECT_MENU); + // Only once the tree is up — while loading or on an error the LoadingFrame + // below owns escape (and retry). + useInput((_input, key) => { + if (key.escape && status.data !== undefined) goBack(); + }); + + if (!status.data) { + return ( + + ); + } + + const select = (node: StatusNode) => { + if (node.data?.route) { + // The detail screens fetch in their context's region, which is the + // ambient one — link with ?region= so the destination fetches where the + // project actually deployed (see useCoreOpts). Escape there is a history + // pop back here. + const region = encodeURIComponent(status.data.target.region); + navigate(`${node.data.route}?region=${region}`); + return; + } + setHint(node.data?.hint); + }; + + return ( + + + Linked Resources + + {nodes.length === 0 ? ( + + No resources are declared in this project. Run `agentcore project add` to declare one. + + ) : ( + + )} + + {hint !== undefined && ( + + {hint} + + )} + + + ); +} diff --git a/src/handlers/project/status/status.screen.test.tsx b/src/handlers/project/status/status.screen.test.tsx new file mode 100644 index 000000000..110b7500e --- /dev/null +++ b/src/handlers/project/status/status.screen.test.tsx @@ -0,0 +1,284 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { + GetAgentRuntimeResponse, + GetHarnessResponse, + GetMemoryOutput, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ProjectSpecSchema } from "../../../projectSchemas/project"; +import { ProjectKey } from "../../../router"; +import { + cleanupScreens, + flatFrame, + renderScreen, + TestCoreClient, + waitForFlatText, + waitForText, +} from "../../../testing"; +import type { Project, ResolvedProjectResource } from "../types"; + +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 })), + ); +}); + +// The target region differs from the base context's us-east-1 on purpose: the +// detail screens must fetch where the project deployed, not in the ambient +// region. +const TARGET = { name: "default", account: "111122223333", region: "eu-west-1" } as const; +const ARN = `arn:aws:bedrock-agentcore:${TARGET.region}:${TARGET.account}`; +const RUNTIME_ID = "checkout-AbCdEf1234"; +const MEMORY_ID = "recallMemory-XyZ123"; +const HARNESS_ID = "support-AbCdEf1234"; + +function project(spec: Record): Project { + return { + name: "orders", + rootPath: "/tmp/orders", + spec: ProjectSpecSchema.parse({ name: "orders", version: 1, ...spec }), + }; +} + +const RUNTIME_PROJECT = project({ + runtimes: [ + { + name: "checkout", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/checkout", + runtimeVersion: "PYTHON_3_14", + }, + ], + memories: [{ name: "recall", eventExpiryDuration: 30 }], +}); + +const deployed = ( + resourceType: ResolvedProjectResource["resourceType"], + name: string, + id: string, + children?: ResolvedProjectResource[], +): ResolvedProjectResource => ({ + resourceType, + name, + ...(children ? { children } : {}), + deploymentState: "deployed", + id, +}); + +const localOnly = ( + resourceType: ResolvedProjectResource["resourceType"], + name: string, +): ResolvedProjectResource => ({ resourceType, name, deploymentState: "local-only" }); + +const RUNTIME_RESOURCES: ResolvedProjectResource[] = [ + deployed("runtime", "checkout", `${ARN}:runtime/${RUNTIME_ID}`), + deployed("memory", "recall", `${ARN}:memory/${MEMORY_ID}`), +]; + +function core(resources: ResolvedProjectResource[] = RUNTIME_RESOURCES): TestCoreClient { + const value = new TestCoreClient(); + value.projectManager.resolveProjectResources = async () => ({ resources, target: TARGET }); + value.runtime.setGetResponse({ + agentRuntimeId: RUNTIME_ID, + agentRuntimeArn: `${ARN}:runtime/${RUNTIME_ID}`, + status: "READY", + } as GetAgentRuntimeResponse); + value.memory.setGetResponse({ + memory: { id: MEMORY_ID, name: "recall", arn: `${ARN}:memory/${MEMORY_ID}`, status: "ACTIVE" }, + } as GetMemoryOutput); + value.harness.setGetResponse({ + harness: { harnessId: HARNESS_ID, harnessName: "support", arn: `${ARN}:harness/${HARNESS_ID}` }, + } as GetHarnessResponse); + return value; +} + +function renderStatus(value: TestCoreClient, seed: Project = RUNTIME_PROJECT) { + return renderScreen("/agentcore/project/status", { + core: value, + withContext: (ctx) => ctx.withValue(ProjectKey, seed), + }); +} + +// focusedLine returns the line carrying the ❯ marker. +function focusedLine(frame: string | undefined): string { + return (frame ?? "").split("\n").find((line) => line.includes("❯")) ?? ""; +} + +describe("project status screen", () => { + test("groups the runtime agent's Runtime and Memory beneath it", async () => { + const screen = renderStatus(core()); + + await waitForText(screen.lastFrame, "Linked Resources"); + const frame = flatFrame(screen.lastFrame); + expect(frame).toContain("checkout agent"); + expect(frame).toMatch(/Runtime\s+checkout deployed/); + expect(frame).toMatch(/Memory\s+recall deployed/); + // Both resources are attributed to the agent — no shared group appears. + expect(frame).not.toContain("project shared resources"); + }); + + test("keeps unattributable resources visible under a shared project group", async () => { + const screen = renderStatus( + core([ + ...RUNTIME_RESOURCES, + deployed("gateway", "tools", `${ARN}:gateway/tools-GwId12345`, [ + deployed("gateway-target", "search", "TARGETID123"), + ]), + localOnly("credential", "svc-key"), + ]), + ); + + await waitForText(screen.lastFrame, "Linked Resources"); + const frame = flatFrame(screen.lastFrame); + expect(frame).toContain("project shared resources"); + expect(frame).toMatch(/Gateway\s+tools deployed/); + expect(frame).toMatch(/Gateway Target\s+search deployed/); + expect(frame).toMatch(/Credential\s+svc-key local-only/); + }); + + test("marks declared-but-undeployed rows local-only and skips them when navigating", async () => { + const screen = renderStatus( + core([ + deployed("runtime", "checkout", `${ARN}:runtime/${RUNTIME_ID}`), + localOnly("memory", "recall"), + ]), + ); + + await waitForText(screen.lastFrame, "local-only"); + // Down from the agent group focuses the Runtime; the disabled Memory row + // cannot take focus, so a second press leaves the focus where it is. + await screen.press("down"); + expect(focusedLine(screen.lastFrame())).toContain("Runtime"); + await screen.press("down"); + expect(focusedLine(screen.lastFrame())).toContain("Runtime"); + }); + + test("enter on the Runtime opens the Runtime detail page in the target region", async () => { + const value = core(); + const screen = renderStatus(value); + + await waitForText(screen.lastFrame, "Linked Resources"); + await screen.press("down"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "agentcore → runtime → get → " + RUNTIME_ID); + await waitForText(screen.lastFrame, "READY"); + const call = value.runtime.calls.find(({ method }) => method === "getRuntime")!; + expect(call.args[0]).toBe(RUNTIME_ID); + expect(call.args[1]).toMatchObject({ region: TARGET.region }); + }); + + test("enter on the Memory opens the Memory detail page in the target region", async () => { + const value = core(); + const screen = renderStatus(value); + + await waitForText(screen.lastFrame, "Linked Resources"); + await screen.press("down"); + await screen.press("down"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "agentcore → memory → get → " + MEMORY_ID); + await waitForText(screen.lastFrame, "ACTIVE"); + const call = value.memory.calls.find(({ method }) => method === "getMemory")!; + expect(call.args[0]).toBe(MEMORY_ID); + expect(call.args[2]).toMatchObject({ region: TARGET.region }); + }); + + test("enter on a Harness opens the Harness detail page", async () => { + const screen = renderStatus( + core([deployed("harness", "support", `${ARN}:harness/${HARNESS_ID}`)]), + project({ harnesses: [{ name: "support", path: "app/support" }] }), + ); + + await waitForText(screen.lastFrame, "Linked Resources"); + await screen.press("down"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "agentcore → harness → get → " + HARNESS_ID); + }); + + test("escape from a detail page returns to the status screen", async () => { + const screen = renderStatus(core()); + + await waitForText(screen.lastFrame, "Linked Resources"); + await screen.press("down"); + await screen.press("return"); + await waitForText(screen.lastFrame, "agentcore → runtime → get"); + + await screen.press("escape"); + await waitForText(screen.lastFrame, "Linked Resources"); + }); + + test("a deployed resource without a detail screen explains itself instead of navigating", async () => { + const screen = renderStatus( + core([...RUNTIME_RESOURCES, deployed("credential", "svc-key", `${ARN}:token-vault/default`)]), + ); + + await waitForText(screen.lastFrame, "Linked Resources"); + // group → runtime → memory → project group → credential. + for (let press = 0; press < 4; press++) await screen.press("down"); + expect(focusedLine(screen.lastFrame())).toContain("Credential"); + await screen.press("return"); + + await waitForText(screen.lastFrame, "Credential svc-key has no detail view."); + expect(screen.lastFrame()).toContain("Linked Resources"); + }); + + test("left and right arrows collapse and expand an agent group", async () => { + const screen = renderStatus(core()); + + await waitForText(screen.lastFrame, "Linked Resources"); + expect(screen.lastFrame()).toContain("Runtime"); + await screen.press("left"); + expect(screen.lastFrame()).not.toContain("Runtime"); + await screen.press("right"); + await waitForText(screen.lastFrame, "Runtime"); + }); + + test("esc returns to the project command menu", async () => { + const screen = renderStatus(core()); + + await waitForText(screen.lastFrame, "Linked Resources"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "manage an AgentCore project"); + }); + + test("shows resolution errors with the standard treatment", async () => { + const value = core(); + value.projectManager.resolveProjectResources = async () => { + throw new Error("No deployment targets are configured for project 'orders'."); + }; + const screen = renderStatus(value); + + await waitForText(screen.lastFrame, "No deployment targets are configured"); + expect(screen.lastFrame()).toContain("✗"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "manage an AgentCore project"); + }); + + test("an empty project reports that nothing is declared", async () => { + const screen = renderStatus(core([]), project({})); + + await waitForText(screen.lastFrame, "No resources are declared in this project."); + }); + + test("reports the CLI's own guidance outside a project", async () => { + const directory = await mkdtemp(join(tmpdir(), "agentcore-status-no-project-")); + tempDirectories.push(directory); + process.chdir(directory); + const screen = renderScreen("/agentcore/project/status", { core: core() }); + + await waitForFlatText(screen.lastFrame, "No AgentCore project found"); + expect(flatFrame(screen.lastFrame)).toContain("agentcore project create"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "manage an AgentCore project"); + }); +}); diff --git a/src/handlers/runtime/get/screen.tsx b/src/handlers/runtime/get/screen.tsx index a144e28ee..a44c49be0 100644 --- a/src/handlers/runtime/get/screen.tsx +++ b/src/handlers/runtime/get/screen.tsx @@ -3,7 +3,7 @@ import { useNavigate, useParams } from "react-router"; import { JsonDetail } from "../../../components/JsonDetail"; import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen"; import type { ScreenProps } from "../../types"; -import { coreOptsFromCtx } from "../../utils"; +import { useCoreOpts } from "../../utils"; const ACTIONS = [ { @@ -30,7 +30,7 @@ const ACTIONS = [ ] as const; function useRuntimeDetail({ ctx, core }: ScreenProps, runtimeId: string | undefined) { - const opts = coreOptsFromCtx(ctx); + const opts = useCoreOpts(ctx); return useQuery({ queryKey: ["runtime", opts.region, runtimeId], queryFn: () => core.runtime.getRuntime(runtimeId!, opts), diff --git a/src/handlers/utils.tsx b/src/handlers/utils.tsx index a87ec614e..4c796cd40 100644 --- a/src/handlers/utils.tsx +++ b/src/handlers/utils.tsx @@ -1,3 +1,4 @@ +import { useSearchParams } from "react-router"; import type { Context } from "../router"; import type z from "zod"; import type { CoreOptions } from "../core/types"; @@ -17,6 +18,19 @@ export function coreOptsFromCtx(ctx: Context): CoreOptions { }; } +// useCoreOpts is coreOptsFromCtx for screens that can be linked to across +// regions. The context's region is the ambient one resolved at launch, which is +// not necessarily where the resource a screen is asked to show lives — project +// status forwards to detail pages on a target that may be deployed elsewhere, +// and links there with `?region=`. A region in the query string +// wins; without one the context's region applies as usual. +export function useCoreOpts(ctx: Context): CoreOptions { + const [search] = useSearchParams(); + const region = search.get("region"); + const opts = coreOptsFromCtx(ctx); + return region ? { ...opts, region } : opts; +} + // parseJsonFlag parses a flag's raw string as JSON, typed as the API structure // the flag mirrors. Structured API parameters (model/tools/memory/...) are // accepted as JSON documents rather than exploded into dozens of leaf flags;