diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index 0cd71420a..da5b313d7 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -16,6 +16,7 @@ import type { } from "./cdk/credentials"; import { DEPLOYED_STATE_RELATIVE_PATH, updateTargetState } from "./cdk/deployedState"; import type { DeployBackendInput } from "./types"; +import type { ResolvedProjectResource } from "../../../handlers/project/types"; import type { BootstrapState } from "./cdk/environment"; import type { CdkCredentialProvider, CdkOperation, CdkOutputs, CdkRunOptions } from "./cdk/toolkit"; @@ -933,3 +934,147 @@ describe("CdkBackend.resolveDeployedResources", () => { expect(subject.stackReads).toEqual([]); }); }); + +describe("CdkBackend.resolveProjectResources", () => { + const out = (ExportName: string, OutputValue: string) => ({ ExportName, OutputValue }); + const key = (OutputKey: string, OutputValue: string) => ({ OutputKey, OutputValue }); + const S = "AgentCore-example-default"; + + test("resolves every declared type: exports, payment OutputKeys, credential from state, nested parents, underscores", async () => { + const input = await project(); + input.spec = { + ...input.spec, + runtimes: [{ name: "web" }], + harnesses: [{ name: "chat" }], + memories: [{ name: "user_mem" }], // an underscore becomes a dash in the export + knowledgeBases: [{ name: "kb" }], + credentials: [{ name: "cred" }], // read from deployed state, not the stack + evaluators: [{ name: "ev" }], + onlineEvalConfigs: [{ name: "oe" }], + agentCoreGateways: [{ name: "gw", targets: [{ name: "tgt" }] }], + policyEngines: [{ name: "pe", policies: [{ name: "pol" }] }], + configBundles: [{ name: "cb" }], + payments: [{ name: "pay", connectors: [{ name: "wallet_one" }] }], + } as unknown as typeof input.spec; + await updateTargetState(json, input.rootPath, TARGET.name, { + stackArn: STACK_ARN, + resources: { credentials: { cred: { credentialProviderArn: "arn:aws:cred/cred" } } }, + }); + const subject = harness({ + describedStack: { + StackName: S, + CreationTime: new Date(0), + StackStatus: "CREATE_COMPLETE", + Outputs: [ + out(`${S}-web-RuntimeArn`, "arn:runtime/web-1"), + out(`${S}-Harness-chat-Arn`, "arn:harness/chat-1"), + out(`${S}-Memory-user-mem-Arn`, "arn:memory/mem-1"), + out(`${S}-KnowledgeBase-kb-Arn`, "arn:kb/kb-1"), + out(`${S}-Evaluator-ev-Arn`, "arn:evaluator/ev-1"), + out(`${S}-OnlineEval-oe-Arn`, "arn:online-eval/oe-1"), + out(`${S}-Gateway-gw-Arn`, "arn:gateway/gw-1"), + out(`${S}-GatewayTarget-tgt-Id`, "tgt-1"), + out(`${S}-PolicyEngine-pe-Arn`, "arn:policy-engine/pe-1"), + out(`${S}-Policy-pe-pol-Arn`, "arn:policy/pol-1"), + out(`${S}-ConfigBundle-cb-Arn`, "arn:config-bundle/cb-1"), + key("PaymentpayManagerArn", "arn:payment-manager/pay-1"), + key("PaymentpaywalletoneConnectorId", "conn-1"), + ], + }, + }); + + const resources = await subject.backend.resolveProjectResources(input, { target: TARGET }); + + // [type, name, arn, [children...]] so a child under the wrong owner fails here + const shape = (resource: ResolvedProjectResource): unknown => [ + resource.resourceType, + resource.name, + resource.deploymentState === "deployed" ? resource.id : undefined, + ...(resource.children ? [resource.children.map(shape)] : []), + ]; + expect(resources.map(shape)).toEqual([ + ["runtime", "web", "arn:runtime/web-1"], + ["harness", "chat", "arn:harness/chat-1"], + ["memory", "user_mem", "arn:memory/mem-1"], + ["knowledge-base", "kb", "arn:kb/kb-1"], + ["credential", "cred", "arn:aws:cred/cred"], + ["evaluator", "ev", "arn:evaluator/ev-1"], + ["online-eval", "oe", "arn:online-eval/oe-1"], + ["gateway", "gw", "arn:gateway/gw-1", [["gateway-target", "tgt", "tgt-1"]]], + ["policy-engine", "pe", "arn:policy-engine/pe-1", [["policy", "pol", "arn:policy/pol-1"]]], + ["config-bundle", "cb", "arn:config-bundle/cb-1"], + [ + "payment-manager", + "pay", + "arn:payment-manager/pay-1", + [["payment-connector", "wallet_one", "conn-1"]], + ], + ]); + expect(subject.stackReads).toHaveLength(1); + }); + + test("reports a declared resource the stack does not publish as local-only", async () => { + const input = await project(); + input.spec = { + ...input.spec, + memories: [{ name: "shortTerm" }, { name: "longTerm" }], + } as unknown as typeof input.spec; + await updateTargetState(json, input.rootPath, TARGET.name, { stackArn: STACK_ARN }); + const subject = harness({ + describedStack: { + StackName: S, + CreationTime: new Date(0), + StackStatus: "CREATE_COMPLETE", + Outputs: [out(`${S}-Memory-shortTerm-Arn`, "arn:memory/short-1")], + }, + }); + + const resources = await subject.backend.resolveProjectResources(input, { target: TARGET }); + + expect(resources).toEqual([ + { + resourceType: "memory", + name: "shortTerm", + deploymentState: "deployed", + id: "arn:memory/short-1", + }, + { resourceType: "memory", name: "longTerm", deploymentState: "local-only" }, + ]); + }); + + test("reports local-only without reading AWS when the target has no recorded stack", async () => { + const input = await project(); + input.spec = { + ...input.spec, + memories: [{ name: "mem" }], + } as unknown as typeof input.spec; + const subject = harness({ describedStack: null }); + + const resources = await subject.backend.resolveProjectResources(input, { target: TARGET }); + + expect(resources).toEqual([ + { resourceType: "memory", name: "mem", deploymentState: "local-only" }, + ]); + expect(subject.stackReads).toEqual([]); + expect(subject.accountCredentials).toEqual([]); + }); + + test("nests a gateway's targets under the gateway", async () => { + const input = await project(); + input.spec = { + ...input.spec, + agentCoreGateways: [{ name: "gw", targets: [{ name: "owned" }, { name: "second" }] }], + } as unknown as typeof input.spec; + const subject = harness({ describedStack: null }); + + const resources = await subject.backend.resolveProjectResources(input, { target: TARGET }); + + expect( + resources.map(({ resourceType, name, children }) => [ + resourceType, + name, + children?.map((child) => child.name), + ]), + ).toEqual([["gateway", "gw", ["owned", "second"]]]); + }); +}); diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index eebeccbd2..74bbf5e0c 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -6,7 +6,9 @@ import type { DeployResult, Project, ProjectEvent, + DeployableResource, ResolvedDeployedResource, + ResolvedProjectResource, } from "../../../handlers/project/types"; import { createLineSplitter, @@ -23,6 +25,7 @@ import type { DeployBackendInput, ProjectBackend, ResolveDeployedResourcesBackendInput, + ResolveProjectResourcesBackendInput, } from "./types"; import { createCloudFormationClient } from "../../factories"; import type { CreateCloudFormationClient } from "../../types"; @@ -69,6 +72,11 @@ type StackDescriber = typeof describeStack; */ const MAX_ERROR_OUTPUT_LINES = 20; +// Payment logical ids drop underscores the same way the template's toCdkId does. +function cdkId(name: string): string { + return name.replace(/_/g, ""); +} + function findDeployedResourceId( stack: Stack, input: Pick, @@ -403,6 +411,140 @@ export class CdkBackend implements ProjectBackend { }); } + public async resolveProjectResources( + project: Project, + input: ResolveProjectResourcesBackendInput, + ): Promise { + const { target } = input; + const { spec } = project; + const deployedState = await readDeployedState(this.json, project.rootPath); + const recorded = deployedState.targets[target.name]; + + // No recorded stack means nothing was ever deployed to this target, which + // every resource below reports as local-only. + const stack = recorded?.stackArn + ? await this.describeStack( + target.region, + await this.credentialsForTarget(target), + recorded.stackArn, + ) + : undefined; + + const byExportName = (...parts: string[]) => { + if (!stack?.StackName) return undefined; + // The CDK library builds every ExportName through this shared helper + // https://github.com/aws/agentcore-l3-cdk-constructs/blob/main/src/cdk/logical-ids.ts#L84 + const want = [stack.StackName, ...parts] + .map((part) => part.replace(/_/g, "-").replace(/[^a-zA-Z0-9:-]/g, "")) + .join("-"); + return stack.Outputs?.find((output) => output.ExportName === want)?.OutputValue; + }; + + const byOutputKey = (key: string) => + stack?.Outputs?.find((output) => output.OutputKey === key)?.OutputValue; + + const arnOf = ( + resourceType: DeployableResource, + name: string, + owner?: string, + ): string | undefined => { + switch (resourceType) { + case "runtime": + return byExportName(name, "RuntimeArn"); + case "harness": + return byExportName("Harness", name, "Arn"); + case "memory": + return byExportName("Memory", name, "Arn"); + case "knowledge-base": + return byExportName("KnowledgeBase", name, "Arn"); + case "evaluator": + return byExportName("Evaluator", name, "Arn"); + case "online-eval": + return byExportName("OnlineEval", name, "Arn"); + case "gateway": + return byExportName("Gateway", name, "Arn"); + case "gateway-target": + // The L3 exports an id for targets and never an ARN + return byExportName("GatewayTarget", name, "Id"); + case "policy": + // ExportName: -Policy---Arn + return byExportName("Policy", owner ?? "", name, "Arn"); + case "policy-engine": + return byExportName("PolicyEngine", name, "Arn"); + case "config-bundle": + return byExportName("ConfigBundle", name, "Arn"); + case "payment-manager": + // The CLI template writes the payment outputs. It does not set an + // exportName on them. Therefore match on the OutputKey. The template + // makes that key from the manager name. + // See src/assets/cdk/lib/cdk-stack.ts + return byOutputKey(`Payment${cdkId(name)}ManagerArn`); + case "payment-connector": + // The same template does not set an exportName. Therefore match on the + // OutputKey. The template writes only a connector id, and never an ARN. + return byOutputKey(`Payment${cdkId(owner ?? "")}${cdkId(name)}ConnectorId`); + case "credential": + // The CLI creates credential providers imperatively. The stack does not + // contain them. Therefore read the ARN from the deployed state file. + return recorded?.resources?.credentials?.[name]?.credentialProviderArn; + default: { + const unhandled: never = resourceType; + return unhandled; + } + } + }; + + // Resolves one declared resource, and keeps its children with it. The spec + // already says which resource owns which, so status never has to pair them + // up again by name. `owner` only builds the export name, so it is not + // reported. + const resolve = ( + resourceType: DeployableResource, + name: string, + options: { owner?: string; children?: ResolvedProjectResource[] } = {}, + ): ResolvedProjectResource => { + const id = arnOf(resourceType, name, options.owner); + return { + resourceType, + name, + ...(options.children?.length ? { children: options.children } : {}), + ...(id ? { deploymentState: "deployed", id } : { deploymentState: "local-only" }), + }; + }; + + return [ + ...spec.runtimes.map(({ name }) => resolve("runtime", name)), + ...spec.harnesses.map(({ name }) => resolve("harness", name)), + ...spec.memories.map(({ name }) => resolve("memory", name)), + ...spec.knowledgeBases.map(({ name }) => resolve("knowledge-base", name)), + ...spec.credentials.map(({ name }) => resolve("credential", name)), + ...spec.evaluators.map(({ name }) => resolve("evaluator", name)), + ...spec.onlineEvalConfigs.map(({ name }) => resolve("online-eval", name)), + ...spec.agentCoreGateways.map((gateway) => + resolve("gateway", gateway.name, { + children: (gateway.targets ?? []).map(({ name }) => + resolve("gateway-target", name, { owner: gateway.name }), + ), + }), + ), + ...spec.policyEngines.map((engine) => + resolve("policy-engine", engine.name, { + children: (engine.policies ?? []).map(({ name }) => + resolve("policy", name, { owner: engine.name }), + ), + }), + ), + ...spec.configBundles.map(({ name }) => resolve("config-bundle", name)), + ...(spec.payments ?? []).map((manager) => + resolve("payment-manager", manager.name, { + children: (manager.connectors ?? []).map(({ name }) => + resolve("payment-connector", name, { owner: manager.name }), + ), + }), + ), + ]; + } + private async credentialsForTarget(target: AwsDeploymentTarget) { const credentials = await this.resolveCredentials(target.region); const account = await this.resolveAccount(target.region, credentials); diff --git a/src/core/project/backends/types.ts b/src/core/project/backends/types.ts index dccb11da8..ca3500234 100644 --- a/src/core/project/backends/types.ts +++ b/src/core/project/backends/types.ts @@ -3,6 +3,7 @@ import type { Project, ProjectEvent, ResolvedDeployedResource, + ResolvedProjectResource, TeardownConfirmationHandler, } from "../../../handlers/project/types"; import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; @@ -18,6 +19,10 @@ export type ResolveDeployedResourcesBackendInput = { target: AwsDeploymentTarget; }; +export type ResolveProjectResourcesBackendInput = { + target: AwsDeploymentTarget; +}; + /** Builds the deployable artifacts owned by a project's selected backend. */ export interface ProjectBackend { build(project: Project): AsyncGenerator; @@ -26,4 +31,15 @@ export interface ProjectBackend { project: Project, input: ResolveDeployedResourcesBackendInput, ): Promise; + /** + * Reports every resource the project declares against the target, including the + * ones it has not deployed. + * + * TODO: merge resolveDeployedResources and resolveProjectResources; the two are + * similar enough that one resolver should serve both invoke and status. + */ + resolveProjectResources( + project: Project, + input: ResolveProjectResourcesBackendInput, + ): Promise; } diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 47b11e5fa..0fb256f13 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -499,6 +499,9 @@ describe("FsProjectManager.deploy", () => { async resolveDeployedResources() { return []; }, + async resolveProjectResources() { + return []; + }, }; return { calls, diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index c502cbdae..435d7b38e 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -13,6 +13,8 @@ import type { ResolvedDeployedResource, ResolvedDeployedResources, ResolveProjectInput, + ResolveProjectResourcesInput, + ResolvedProjectResources, ResolveTargetInput, Project, ProjectManager, @@ -935,6 +937,15 @@ export class FsProjectManager implements ProjectManager { return { resources, target }; } + public async resolveProjectResources( + project: Project, + input: ResolveProjectResourcesInput, + ): Promise { + const target = await this.resolveExistingTarget(project, input.target); + const resources = await this.backendFor(project).resolveProjectResources(project, { target }); + return { resources, target }; + } + private async resolveExistingTarget( project: Project, name: string, diff --git a/src/handlers/project/build/index.test.ts b/src/handlers/project/build/index.test.ts index 845ef8036..a286fb512 100644 --- a/src/handlers/project/build/index.test.ts +++ b/src/handlers/project/build/index.test.ts @@ -32,6 +32,9 @@ function testBuildCommand(options: TestBuildOptions = {}) { async resolveDeployedResources() { return []; }, + async resolveProjectResources() { + return []; + }, }; const core = new TestCoreClient({ backends: { CDK: backend } }); const root = createRootHandler(core, { diff --git a/src/handlers/project/deploy/index.test.ts b/src/handlers/project/deploy/index.test.ts index 69b163cd3..909ceb73c 100644 --- a/src/handlers/project/deploy/index.test.ts +++ b/src/handlers/project/deploy/index.test.ts @@ -68,6 +68,9 @@ function fakeBackend( async resolveDeployedResources() { return []; }, + async resolveProjectResources() { + return []; + }, }; return { calls, confirmations, backend }; } diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 3aabbf426..91805662e 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -91,7 +91,11 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router ), ); project.handler(createProjectInvokeHandler(core, io)); - project.handler(createStatusProjectHandler()); + project.handler( + withProject({ projectManager: config.projectManager })( + createStatusProjectHandler({ projectManager: config.projectManager }), + ), + ); // 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/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx index 91ce9dd4f..58b951e34 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -95,6 +95,9 @@ function backend() { })), ]; }, + async resolveProjectResources() { + throw new Error("project invoke resolves deployed resources, not project resources"); + }, }; return { calls, value }; } diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 200f55ce6..ae7083125 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -25,10 +25,9 @@ async function run(args: string[], opts?: { core?: TestCoreClient; stdin?: strin return { io, core }; } -describe.each(["status"])("project %s", (command) => { - test("throws because it is not implemented yet", async () => { - await expect(run([command])).rejects.toThrow(/not implemented/); - }); +test("project status requires an AgentCore project", async () => { + await inTempDirectory(); + await expect(run(["status"])).rejects.toThrow(/No AgentCore project found/); }); test("project dev requires an AgentCore project", async () => { diff --git a/src/handlers/project/status/index.test.ts b/src/handlers/project/status/index.test.ts new file mode 100644 index 000000000..99e5647cc --- /dev/null +++ b/src/handlers/project/status/index.test.ts @@ -0,0 +1,234 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createRootHandler } from "../../index"; +import { + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../../testing"; +import type { ProjectBackend } from "../../../core/project"; +import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; +import type { ResolvedProjectResource } from "../types"; + +const DEFAULT_TARGET: AwsDeploymentTarget = { + name: "default", + account: "111122223333", + region: "us-east-1", +}; +const STAGING_TARGET: AwsDeploymentTarget = { + name: "staging", + account: "444455556666", + region: "eu-west-1", +}; +const TARGETS = [DEFAULT_TARGET, STAGING_TARGET]; +const ARN = `arn:aws:bedrock-agentcore:${DEFAULT_TARGET.region}:${DEFAULT_TARGET.account}`; + +function fakeBackend(deployed: ResolvedProjectResource[]) { + const targets: AwsDeploymentTarget[] = []; + const backend: ProjectBackend = { + async *build() {}, + async *deploy() { + yield { type: "step", message: "unused by these tests" }; + return { outputs: {} }; + }, + async resolveDeployedResources() { + throw new Error("project status resolves project resources, not deployed resources"); + }, + async resolveProjectResources(_project, input) { + targets.push(input.target); + return deployed; + }, + }; + return { targets, backend }; +} + +function testStatusCommand(deployed: ResolvedProjectResource[] = []) { + const io = testIO(); + const fake = fakeBackend(deployed); + const root = createRootHandler(new TestCoreClient({ backends: { CDK: fake.backend } }), { + io: io.io, + globalConfigAccessor: new TestGlobalConfigAccessor(), + logger: createSilentLogger(), + }); + + return { + ...fake, + io, + json: () => JSON.parse(io.stdout()), + run: (args: string[] = []) => root.route(["node", "agentcore", "project", "status", ...args]), + create: (args: string[]) => root.route(["node", "agentcore", "project", ...args]), + }; +} + +const originalCwd = process.cwd(); +const tempDirectories: string[] = []; + +afterEach(async () => { + process.chdir(originalCwd); + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +async function inProject( + subject: ReturnType, + spec: Record = {}, + targets: AwsDeploymentTarget[] = TARGETS, +): Promise { + const directory = await mkdtemp(join(tmpdir(), "agentcore-status-")); + tempDirectories.push(directory); + process.chdir(directory); + await subject.create(["create", "--name", "orders", "--skip-install", "--skip-git"]); + const projectRoot = join(process.cwd(), "orders"); + await writeFile(join(projectRoot, "agentcore", "aws-targets.json"), JSON.stringify(targets)); + const specPath = join(projectRoot, "agentcore", "agentcore.json"); + const current = JSON.parse(await Bun.file(specPath).text()); + await writeFile(specPath, JSON.stringify({ ...current, ...spec })); + process.chdir(projectRoot); +} + +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, + children?: ResolvedProjectResource[], +): ResolvedProjectResource => ({ + resourceType, + name, + ...(children ? { children } : {}), + deploymentState: "local-only", +}); + +const HARNESS_ROW = localOnly("harness", "orders"); + +const memory = (name: string) => ({ name, eventExpiryDuration: 30 }); +const policy = (name: string) => ({ name, statement: "permit(principal, action, resource);" }); +describe("project status handler", () => { + test("reports deployed resources by ARN, nesting children under their owner", async () => { + const subject = testStatusCommand([ + HARNESS_ROW, + deployed("memory", "shortTerm", `${ARN}:memory/shortTerm-1`), + deployed("policy-engine", "guards", `${ARN}:policy-engine/guards-1`, [ + deployed("policy", "noPii", `${ARN}:policy/noPii-1`), + ]), + localOnly("policy-engine", "empty"), + ]); + await inProject(subject, { + memories: [memory("shortTerm")], + policyEngines: [ + { name: "guards", policies: [policy("noPii")] }, + { name: "empty", policies: [] }, + ], + }); + + await subject.run(); + + expect(subject.json()).toEqual({ + projectName: "orders", + target: "default", + region: "us-east-1", + resources: [ + HARNESS_ROW, + { + resourceType: "memory", + name: "shortTerm", + deploymentState: "deployed", + id: `${ARN}:memory/shortTerm-1`, + }, + { + resourceType: "policy-engine", + name: "guards", + deploymentState: "deployed", + id: `${ARN}:policy-engine/guards-1`, + children: [ + { + resourceType: "policy", + name: "noPii", + deploymentState: "deployed", + id: `${ARN}:policy/noPii-1`, + }, + ], + }, + { resourceType: "policy-engine", name: "empty", deploymentState: "local-only" }, + ], + }); + }); + + test("omits identifier for resources the stack does not hold", async () => { + const subject = testStatusCommand([ + HARNESS_ROW, + deployed("memory", "shortTerm", `${ARN}:memory/shortTerm-1`), + localOnly("memory", "longTerm"), + ]); + await inProject(subject, { memories: [memory("shortTerm"), memory("longTerm")] }); + + await subject.run(); + + expect(subject.json().resources).toEqual([ + HARNESS_ROW, + { + resourceType: "memory", + name: "shortTerm", + deploymentState: "deployed", + id: `${ARN}:memory/shortTerm-1`, + }, + { resourceType: "memory", name: "longTerm", deploymentState: "local-only" }, + ]); + }); + + test("reports every resource local-only when nothing is deployed", async () => { + const subject = testStatusCommand([HARNESS_ROW, localOnly("memory", "shortTerm")]); + await inProject(subject, { memories: [memory("shortTerm")] }); + + await subject.run(); + + expect(subject.json()).toEqual({ + projectName: "orders", + target: "default", + region: "us-east-1", + resources: [ + HARNESS_ROW, + { resourceType: "memory", name: "shortTerm", deploymentState: "local-only" }, + ], + }); + }); + + test("rejects a project that declares no targets, without reaching the backend", async () => { + const subject = testStatusCommand([localOnly("memory", "shortTerm")]); + await inProject(subject, { memories: [memory("shortTerm")] }, []); + + await expect(subject.run()).rejects.toThrow( + /No deployment targets are configured for project 'orders'/, + ); + expect(subject.targets).toEqual([]); + }); + + test("--target selects another target, and an unknown one is rejected", async () => { + const subject = testStatusCommand([]); + await inProject(subject); + + await subject.run(["--target", "staging"]); + + expect(subject.targets).toEqual([STAGING_TARGET]); + expect(subject.json()).toMatchObject({ target: "staging", region: "eu-west-1" }); + + await expect(subject.run(["--target", "typo"])).rejects.toThrow( + /has no deployment target named 'typo'/, + ); + }); +}); diff --git a/src/handlers/project/status/index.ts b/src/handlers/project/status/index.ts index d5b9ad766..e91179b45 100644 --- a/src/handlers/project/status/index.ts +++ b/src/handlers/project/status/index.ts @@ -1,11 +1,43 @@ -import { createHandler } from "../../../router"; -import { NotImplementedError } from "../../../errors"; +import z from "zod"; +import { DEFAULT_TARGET_NAME } from "../../../projectSchemas/aws-targets"; +import { createHandler, flag, ProjectKey } from "../../../router"; +import { JsonRendererKey } from "../../../tui"; +import type { ProjectManager, ResolvedProjectResource } from "../types"; -export const createStatusProjectHandler = () => +type StatusProjectHandlerConfig = { + projectManager: ProjectManager; +}; + +type ProjectStatus = { + projectName: string; + target: string; + region: string; + resources: ResolvedProjectResource[]; +}; + +export const createStatusProjectHandler = (config: StatusProjectHandlerConfig) => createHandler({ name: "status", description: "show the status of the project's deployed resources", - handle: async () => { - throw new NotImplementedError("agentcore project status is not implemented yet"); + flags: [ + flag( + "target", + "name of the aws-targets.json entry to report on", + z.string().default(DEFAULT_TARGET_NAME), + ), + ], + handle: async (ctx, flags) => { + const project = ctx.require(ProjectKey); + const resolved = await config.projectManager.resolveProjectResources(project, { + target: flags.target, + }); + + const status: ProjectStatus = { + projectName: project.name, + target: resolved.target.name, + region: resolved.target.region, + resources: resolved.resources, + }; + ctx.require(JsonRendererKey).renderJson(status); }, }); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index dc2dd279d..d6060e7b4 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -181,6 +181,53 @@ export type ResolvedDeployedResources = { target: AwsDeploymentTarget; }; +export type ResolveProjectResourcesInput = { + /** Name of the aws-targets.json entry to report on. */ + target: string; +}; + +/** + * Every resource type a project can declare and deploy. Broader than + * {@link ProjectInvokableResource}: status reports all of them, while invoke only + * addresses the two that accept a payload. + */ +export type DeployableResource = + | "runtime" + | "harness" + | "memory" + | "knowledge-base" + | "credential" + | "evaluator" + | "online-eval" + | "gateway" + | "gateway-target" + | "policy-engine" + | "policy" + | "config-bundle" + | "payment-manager" + | "payment-connector"; + +/** + * A declared resource paired with what the target holds for it. `local-only` + * means the project declares it but the target's stack has not published it, + * which is how status distinguishes "not deployed yet" from "not declared". + */ +export type ResolvedProjectResource = { + resourceType: DeployableResource; + name: string; + /** + * Resources this one contains: a gateway's targets, a policy engine's + * policies, a payment manager's connectors. The spec says who owns what, so + * the resolver nests them here and no caller pairs them up by name. + */ + children?: ResolvedProjectResource[]; +} & ({ deploymentState: "deployed"; id: string } | { deploymentState: "local-only" }); + +export type ResolvedProjectResources = { + resources: ResolvedProjectResource[]; + target: AwsDeploymentTarget; +}; + export type Project = { name: string; /** Absolute path to the project root (the parent of agentcore/). */ @@ -374,6 +421,15 @@ export interface ProjectManager { input: ResolveDeployedResourcesInput, ): Promise; + /** + * Resolve every resource the project declares, deployed or not, for the named + * target. Reports rather than throws when nothing is deployed yet. + */ + resolveProjectResources( + project: Project, + input: ResolveProjectResourcesInput, + ): Promise; + /** Add a resource to an existing AgentCore project. */ addResource(project: Project, input: AddResourceInput): AsyncGenerator;