From 9f86ab5eca181a0c5bd648d6a45013068049bc1d Mon Sep 17 00:00:00 2001 From: jariy17 Date: Tue, 1 Sep 2026 17:29:40 +0000 Subject: [PATCH 01/15] feat(project): resolve all deployed resource types, not just runtimes+harnesses Broaden ProjectManager.resolveDeployedResources so it returns every deployed resource (memory, knowledge-base, credential, evaluator, online-eval, gateway, gateway-target, policy-engine, policy, config-bundle, dataset, payment) with its physical id, alongside the existing runtimes and harnesses. - Widen DeployedProjectResource.resourceType to a new DeployableResource union; add optional parent (policy->engine, gateway-target->gateway). - Generalize findDeployedResourceId with an EXPORT_PARTS table typed 'satisfies Record' so a missing type is a COMPILE error, never a silent miss. ExportName format replicated from @aws/agentcore-cdk logical-ids (not importable: CDK lib, not a CLI dep). - credential ids come from the deployed-state credentials map (never a stack output); payment matches by OutputKey (its CfnOutputs set no ExportName) with a TODO to add exportName in the cdk and fold it in. - Add allowMissing so an undeployed target yields [] instead of throwing; deploy and remove keep the hard failure. Stack resolution unchanged (stackArn-from-state). - Guard the invoke picker to only list runtime/harness now that the resolver returns more types. This function will back the new 'agentcore project status' handler, which returns a JSON status of the project's deployed resources to the customer. --- src/core/project/backends/cdk.test.ts | 123 +++++++++++++++++++++++++ src/core/project/backends/cdk.ts | 118 +++++++++++++++++++++--- src/core/project/backends/types.ts | 2 + src/core/project/manager.tsx | 5 +- src/handlers/project/invoke/screen.tsx | 37 +++++--- src/handlers/project/types.ts | 36 +++++++- 6 files changed, 290 insertions(+), 31 deletions(-) diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index 0cd71420a..ee627c114 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -932,4 +932,127 @@ describe("CdkBackend.resolveDeployedResources", () => { ).rejects.toThrow(/expects AWS account 111122223333.*999900001111/s); expect(subject.stackReads).toEqual([]); }); + + test("resolves every deployed resource type: exports, payment OutputKey, credential from state, nested parents, underscores", async () => { + const input = await project(); + // The resolver only reads names (and nested target/policy names), so a + // hand-shaped spec is enough here — schema validity is tested elsewhere. + input.spec = { + ...input.spec, + runtimes: [{ name: "web" }], + harnesses: [{ name: "chat" }], + memories: [{ name: "user_mem" }], // underscore must map to -user-mem- + knowledgeBases: [{ name: "kb" }], + credentials: [{ name: "cred" }], // id comes from deployed-state, not outputs + evaluators: [{ name: "ev" }], + onlineEvalConfigs: [{ name: "oe" }], + agentCoreGateways: [{ name: "gw", targets: [{ name: "tgt" }] }], + policyEngines: [{ name: "pe", policies: [{ name: "pol" }] }], + configBundles: [{ name: "cb" }], + datasets: [{ name: "ds" }], + payments: [{ name: "pay" }], + } 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 S = "AgentCore-example-default"; + const out = (ExportName: string, OutputValue: string) => ({ ExportName, OutputValue }); + const subject = harness({ + describedStack: { + StackName: S, + CreationTime: new Date(0), + StackStatus: "CREATE_COMPLETE", + Outputs: [ + out(`${S}-web-RuntimeId`, "web-1"), + out(`${S}-Harness-chat-Id`, "chat-1"), + out(`${S}-Memory-user-mem-Id`, "mem-1"), + out(`${S}-KnowledgeBase-kb-Id`, "kb-1"), + out(`${S}-Evaluator-ev-Id`, "ev-1"), + out(`${S}-OnlineEval-oe-Id`, "oe-1"), + out(`${S}-Gateway-gw-Id`, "gw-1"), + out(`${S}-GatewayTarget-tgt-Id`, "tgt-1"), + out(`${S}-PolicyEngine-pe-Id`, "pe-1"), + out(`${S}-Policy-pe-pol-Id`, "pol-1"), + out(`${S}-ConfigBundle-cb-Id`, "cb-1"), + out(`${S}-Dataset-ds-Id`, "ds-1"), + // payment: no ExportName — only a predictable OutputKey + { OutputKey: "PaymentpayManagerId", OutputValue: "pay-1" }, + ], + }, + }); + + const resources = await subject.backend.resolveDeployedResources(input, { target: TARGET }); + + expect(resources).toEqual([ + { resourceType: "runtime", name: "web", id: "web-1", target: TARGET }, + { resourceType: "harness", name: "chat", id: "chat-1", target: TARGET }, + { resourceType: "memory", name: "user_mem", id: "mem-1", target: TARGET }, + { resourceType: "knowledge-base", name: "kb", id: "kb-1", target: TARGET }, + { resourceType: "credential", name: "cred", id: "arn:aws:cred/cred", target: TARGET }, + { resourceType: "evaluator", name: "ev", id: "ev-1", target: TARGET }, + { resourceType: "online-eval", name: "oe", id: "oe-1", target: TARGET }, + { resourceType: "gateway", name: "gw", id: "gw-1", target: TARGET }, + { resourceType: "gateway-target", name: "tgt", parent: "gw", id: "tgt-1", target: TARGET }, + { resourceType: "policy-engine", name: "pe", id: "pe-1", target: TARGET }, + { resourceType: "policy", name: "pol", parent: "pe", id: "pol-1", target: TARGET }, + { resourceType: "config-bundle", name: "cb", id: "cb-1", target: TARGET }, + { resourceType: "dataset", name: "ds", id: "ds-1", target: TARGET }, + { resourceType: "payment", name: "pay", id: "pay-1", target: TARGET }, + ]); + expect(subject.stackReads).toHaveLength(1); + }); + + test("omits a declared non-runtime resource that has no deployed output", async () => { + const input = await project(); + input.spec = { + ...input.spec, + runtimes: [], + harnesses: [], + memories: [{ name: "mem" }], + } as unknown as typeof input.spec; + await updateTargetState(json, input.rootPath, TARGET.name, { stackArn: STACK_ARN }); + const subject = harness({ + describedStack: { + StackName: "AgentCore-example-default", + CreationTime: new Date(0), + StackStatus: "CREATE_COMPLETE", + Outputs: [], + }, + }); + + await expect( + subject.backend.resolveDeployedResources(input, { target: TARGET }), + ).resolves.toEqual([]); + }); + + test("allowMissing returns [] instead of throwing when the target has no stack ARN", async () => { + const input = await project(); + const subject = harness({ describedStack: null }); + + await expect( + subject.backend.resolveDeployedResources(input, { target: TARGET, allowMissing: true }), + ).resolves.toEqual([]); + expect(subject.stackReads).toEqual([]); + }); + + test("allowMissing returns [] instead of throwing when the recorded stack is gone", async () => { + const input = await project(); + await updateTargetState(json, input.rootPath, TARGET.name, { stackArn: STACK_ARN }); + const subject = harness({ describedStack: null }); + + await expect( + subject.backend.resolveDeployedResources(input, { target: TARGET, allowMissing: true }), + ).resolves.toEqual([]); + }); + + test("allowMissing does not swallow a wrong-account error", async () => { + const input = await project(); + await updateTargetState(json, input.rootPath, TARGET.name, { stackArn: STACK_ARN }); + const subject = harness({ account: "999900001111" }); + + await expect( + subject.backend.resolveDeployedResources(input, { target: TARGET, allowMissing: true }), + ).rejects.toThrow(/expects AWS account 111122223333.*999900001111/s); + }); }); diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index eebeccbd2..ec23da0bf 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import type { Stack } from "@aws-sdk/client-cloudformation"; import { MalformedServiceResponseError, ProjectStateError } from "../../../errors/errors"; import type { + DeployableResource, DeployResult, Project, ProjectEvent, @@ -69,17 +70,61 @@ type StackDescriber = typeof describeStack; */ const MAX_ERROR_OUTPUT_LINES = 20; +// Mirrors @aws/agentcore-cdk's exportName() (its src/cdk/logical-ids.ts): join the +// parts with "-" after turning "_" into "-" and dropping anything outside +// [A-Za-z0-9:-]. Replicated rather than imported because that package is a CDK +// construct library, not a CLI dependency — this is the source-of-truth format. +function cfnExportName(...parts: string[]): string { + return parts.map((part) => part.replace(/_/g, "-").replace(/[^a-zA-Z0-9:-]/g, "")).join("-"); +} + +// toCdkId mirrors the payment CfnOutput logical-id construction in the CLI's own +// cdk-stack.ts (assets/cdk/lib/cdk-stack.ts): underscores stripped, rest kept. +function toCdkId(name: string): string { + return name.replace(/_/g, ""); +} + +// The exportName parts (after the stack name) for every type whose deployed id is +// a CloudFormation export. credential + payment are excluded on purpose — credential +// comes from deployed-state, payment matches by OutputKey below. `satisfies Record` +// makes this exhaustive: adding a DeployableResource without a row here is a compile +// error, so a new type can never silently resolve to "not found". +type CfnOutputResource = Exclude; + +const EXPORT_PARTS = { + runtime: (name) => [name, "RuntimeId"], + harness: (name) => ["Harness", name, "Id"], + memory: (name) => ["Memory", name, "Id"], + "knowledge-base": (name) => ["KnowledgeBase", name, "Id"], + evaluator: (name) => ["Evaluator", name, "Id"], + "online-eval": (name) => ["OnlineEval", name, "Id"], + gateway: (name) => ["Gateway", name, "Id"], + "gateway-target": (name) => ["GatewayTarget", name, "Id"], + "policy-engine": (name) => ["PolicyEngine", name, "Id"], + policy: (name, parent) => ["Policy", parent ?? "", name, "Id"], + "config-bundle": (name) => ["ConfigBundle", name, "Id"], + dataset: (name) => ["Dataset", name, "Id"], + // Output arrives with aws/agentcore-l3-cdk-constructs#336; resolves once it ships. + "capacity-provider": (name) => ["CapacityProvider", name, "Id"], +} satisfies Record string[]>; + function findDeployedResourceId( stack: Stack, - input: Pick, + input: { resourceType: Exclude; name: string; parent?: string }, ): string | undefined { if (!stack.StackName) return undefined; - const exportResourceName = input.name.replaceAll("_", "-"); - const exportName = - input.resourceType === "runtime" - ? `${stack.StackName}-${exportResourceName}-RuntimeId` - : `${stack.StackName}-Harness-${exportResourceName}-Id`; - return stack.Outputs?.find((output) => output.ExportName === exportName)?.OutputValue; + // TODO(cdk): the CLI's payment CfnOutputs (assets/cdk/lib/cdk-stack.ts) set no + // ExportName, so match by their predictable OutputKey. Once they export a name, + // fold payment into EXPORT_PARTS and delete this branch. + if (input.resourceType === "payment") { + const key = `Payment${toCdkId(input.name)}ManagerId`; + return stack.Outputs?.find((output) => output.OutputKey === key)?.OutputValue; + } + const want = cfnExportName( + stack.StackName, + ...EXPORT_PARTS[input.resourceType](input.name, input.parent), + ); + return stack.Outputs?.find((output) => output.ExportName === want)?.OutputValue; } export type CdkBackendConfig = { @@ -374,10 +419,11 @@ export class CdkBackend implements ProjectBackend { project: Project, input: ResolveDeployedResourcesBackendInput, ): Promise { - const { target } = input; + const { target, allowMissing } = input; const deployedState = await readDeployedState(this.json, project.rootPath); const stackArn = deployedState.targets[target.name]?.stackArn; if (!stackArn) { + if (allowMissing) return []; throw new ProjectStateError( `Project '${project.name}' is not deployed to target '${target.name}'. ` + `Run 'agentcore project deploy --target ${target.name}' first.`, @@ -387,19 +433,63 @@ export class CdkBackend implements ProjectBackend { const credentials = await this.credentialsForTarget(target); const stack = await this.describeStack(target.region, credentials, stackArn); if (!stack) { + if (allowMissing) return []; throw new ProjectStateError( `Project '${project.name}' is not deployed to target '${target.name}'. ` + `Run 'agentcore project deploy --target ${target.name}' first.`, ); } - const resources = [ - ...project.spec.runtimes.map(({ name }) => ({ resourceType: "runtime" as const, name })), - ...project.spec.harnesses.map(({ name }) => ({ resourceType: "harness" as const, name })), + const { spec } = project; + // Credential ids are never stack outputs — they're created imperatively and + // recorded in deployed-state. Read them from the state we already loaded. + const credentialArns = deployedState.targets[target.name]?.resources?.credentials ?? {}; + + type Declared = { resourceType: DeployableResource; name: string; parent?: string }; + const declared: Declared[] = [ + ...spec.runtimes.map(({ name }) => ({ resourceType: "runtime" as const, name })), + ...spec.harnesses.map(({ name }) => ({ resourceType: "harness" as const, name })), + ...spec.memories.map(({ name }) => ({ resourceType: "memory" as const, name })), + ...spec.knowledgeBases.map(({ name }) => ({ resourceType: "knowledge-base" as const, name })), + ...spec.credentials.map(({ name }) => ({ resourceType: "credential" as const, name })), + ...spec.evaluators.map(({ name }) => ({ resourceType: "evaluator" as const, name })), + ...spec.onlineEvalConfigs.map(({ name }) => ({ resourceType: "online-eval" as const, name })), + ...spec.agentCoreGateways.flatMap((gw) => [ + { resourceType: "gateway" as const, name: gw.name }, + ...(gw.targets ?? []).map(({ name }) => ({ + resourceType: "gateway-target" as const, + name, + parent: gw.name, + })), + ]), + ...(spec.unassignedTargets ?? []).map(({ name }) => ({ + resourceType: "gateway-target" as const, + name, + })), + ...spec.policyEngines.flatMap((engine) => [ + { resourceType: "policy-engine" as const, name: engine.name }, + ...(engine.policies ?? []).map(({ name }) => ({ + resourceType: "policy" as const, + name, + parent: engine.name, + })), + ]), + ...spec.configBundles.map(({ name }) => ({ resourceType: "config-bundle" as const, name })), + ...(spec.datasets ?? []).map(({ name }) => ({ resourceType: "dataset" as const, name })), + ...(spec.payments ?? []).map(({ name }) => ({ resourceType: "payment" as const, name })), + // capacity-provider has no spec array yet — arrives with l3-cdk-constructs#336. ]; - return resources.flatMap((resource) => { - const id = findDeployedResourceId(stack, resource); - return id ? [{ ...resource, id, target }] : []; + + return declared.flatMap((r) => { + const id = + r.resourceType === "credential" + ? credentialArns[r.name]?.credentialProviderArn + : findDeployedResourceId(stack, { + resourceType: r.resourceType, + name: r.name, + parent: r.parent, + }); + return id ? [{ ...r, id, target }] : []; }); } diff --git a/src/core/project/backends/types.ts b/src/core/project/backends/types.ts index dccb11da8..0ac642596 100644 --- a/src/core/project/backends/types.ts +++ b/src/core/project/backends/types.ts @@ -16,6 +16,8 @@ export type DeployBackendInput = { export type ResolveDeployedResourcesBackendInput = { target: AwsDeploymentTarget; + /** When true, an undeployed target yields [] instead of throwing. */ + allowMissing?: boolean; }; /** Builds the deployable artifacts owned by a project's selected backend. */ diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 6f10ec39a..1f9633542 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -954,7 +954,10 @@ export class FsProjectManager implements ProjectManager { input: ResolveDeployedResourcesInput, ): Promise { const target = await this.resolveExistingTarget(project, input.target); - const resources = await this.backendFor(project).resolveDeployedResources(project, { target }); + const resources = await this.backendFor(project).resolveDeployedResources(project, { + target, + allowMissing: input.allowMissing, + }); return { resources, target }; } diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx index 00ba1386a..1d7501250 100644 --- a/src/handlers/project/invoke/screen.tsx +++ b/src/handlers/project/invoke/screen.tsx @@ -83,24 +83,31 @@ export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) { const rows = useMemo( () => - (deployed?.resources ?? []).map((resource) => { - if (resource.resourceType === "runtime") { - const configured = project?.spec.runtimes.find(({ name }) => name === resource.name); + // resolveDeployedResources now returns every deployed resource type, but only + // runtimes and harnesses are invokable — drop the rest so they aren't listed. + (deployed?.resources ?? []) + .filter( + (r): r is typeof r & { resourceType: "runtime" | "harness" } => + r.resourceType === "runtime" || r.resourceType === "harness", + ) + .map((resource) => { + if (resource.resourceType === "runtime") { + const configured = project?.spec.runtimes.find(({ name }) => name === resource.name); + return { + ...resource, + type: "Runtime" as const, + protocol: configured?.protocol ?? "HTTP", + source: configured?.codeLocation ?? "-", + }; + } + const configured = project?.spec.harnesses.find(({ name }) => name === resource.name); return { ...resource, - type: "Runtime" as const, - protocol: configured?.protocol ?? "HTTP", - source: configured?.codeLocation ?? "-", + type: "Harness" as const, + protocol: "-", + source: configured?.path ?? "-", }; - } - const configured = project?.spec.harnesses.find(({ name }) => name === resource.name); - return { - ...resource, - type: "Harness" as const, - protocol: "-", - source: configured?.path ?? "-", - }; - }), + }), [deployed, project], ); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 59f97a578..b8dcd2b33 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -168,12 +168,46 @@ export type ResolveDeployedResourceInput = { export type ResolveDeployedResourcesInput = { target: string; + /** + * When true, an undeployed target resolves to an empty resource list instead + * of throwing. `project status` wants to render every declared resource as + * local-only rather than error out before the stack exists; deploy/remove + * still want the hard failure, so it stays opt-in. + */ + allowMissing?: boolean; }; +/** + * Every project resource type that can be surfaced as deployed. Broader than + * {@link ProjectInvokableResource} (runtime/harness) because `project status` + * reports the whole stack, not just what you can invoke. Not derived from + * {@link ProjectResource}: the deployed vocabulary differs (e.g. `payment`, not + * `payment-manager`/`payment-connector`; adds `knowledge-base`, `dataset`, + * `capacity-provider`). + */ +export type DeployableResource = + | "runtime" + | "harness" + | "memory" + | "knowledge-base" + | "credential" + | "evaluator" + | "online-eval" + | "gateway" + | "gateway-target" + | "policy-engine" + | "policy" + | "config-bundle" + | "dataset" + | "payment" + | "capacity-provider"; + export type ResolvedDeployedResource = { - resourceType: ProjectInvokableResource; + resourceType: DeployableResource; name: string; id: string; + /** Owner name for nested types: policy → engine, gateway-target → gateway. */ + parent?: string; target: AwsDeploymentTarget; }; From d49ba347bf18dd68c60b6b0b0d83136382ad55f8 Mon Sep 17 00:00:00 2001 From: jariy17 Date: Tue, 1 Sep 2026 18:27:49 +0000 Subject: [PATCH 02/15] refactor(project): drop dataset from deployed resource scope Datasets are out of scope for 'agentcore project status', so remove them from the resolver: the DeployableResource union, the EXPORT_PARTS table, the spec iteration, and the resolver test. The 'satisfies Record' guard proves the union and the table stayed in sync after the removal. --- src/core/project/backends/cdk.test.ts | 3 --- src/core/project/backends/cdk.ts | 3 +-- src/handlers/project/types.ts | 5 ++--- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index ee627c114..a0d4b796a 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -949,7 +949,6 @@ describe("CdkBackend.resolveDeployedResources", () => { agentCoreGateways: [{ name: "gw", targets: [{ name: "tgt" }] }], policyEngines: [{ name: "pe", policies: [{ name: "pol" }] }], configBundles: [{ name: "cb" }], - datasets: [{ name: "ds" }], payments: [{ name: "pay" }], } as unknown as typeof input.spec; await updateTargetState(json, input.rootPath, TARGET.name, { @@ -975,7 +974,6 @@ describe("CdkBackend.resolveDeployedResources", () => { out(`${S}-PolicyEngine-pe-Id`, "pe-1"), out(`${S}-Policy-pe-pol-Id`, "pol-1"), out(`${S}-ConfigBundle-cb-Id`, "cb-1"), - out(`${S}-Dataset-ds-Id`, "ds-1"), // payment: no ExportName — only a predictable OutputKey { OutputKey: "PaymentpayManagerId", OutputValue: "pay-1" }, ], @@ -997,7 +995,6 @@ describe("CdkBackend.resolveDeployedResources", () => { { resourceType: "policy-engine", name: "pe", id: "pe-1", target: TARGET }, { resourceType: "policy", name: "pol", parent: "pe", id: "pol-1", target: TARGET }, { resourceType: "config-bundle", name: "cb", id: "cb-1", target: TARGET }, - { resourceType: "dataset", name: "ds", id: "ds-1", target: TARGET }, { resourceType: "payment", name: "pay", id: "pay-1", target: TARGET }, ]); expect(subject.stackReads).toHaveLength(1); diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index ec23da0bf..28dac8aa2 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -103,7 +103,6 @@ const EXPORT_PARTS = { "policy-engine": (name) => ["PolicyEngine", name, "Id"], policy: (name, parent) => ["Policy", parent ?? "", name, "Id"], "config-bundle": (name) => ["ConfigBundle", name, "Id"], - dataset: (name) => ["Dataset", name, "Id"], // Output arrives with aws/agentcore-l3-cdk-constructs#336; resolves once it ships. "capacity-provider": (name) => ["CapacityProvider", name, "Id"], } satisfies Record string[]>; @@ -475,7 +474,7 @@ export class CdkBackend implements ProjectBackend { })), ]), ...spec.configBundles.map(({ name }) => ({ resourceType: "config-bundle" as const, name })), - ...(spec.datasets ?? []).map(({ name }) => ({ resourceType: "dataset" as const, name })), + // datasets are intentionally excluded — out of scope for project status. ...(spec.payments ?? []).map(({ name }) => ({ resourceType: "payment" as const, name })), // capacity-provider has no spec array yet — arrives with l3-cdk-constructs#336. ]; diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index b8dcd2b33..09f70bbf7 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -182,8 +182,8 @@ export type ResolveDeployedResourcesInput = { * {@link ProjectInvokableResource} (runtime/harness) because `project status` * reports the whole stack, not just what you can invoke. Not derived from * {@link ProjectResource}: the deployed vocabulary differs (e.g. `payment`, not - * `payment-manager`/`payment-connector`; adds `knowledge-base`, `dataset`, - * `capacity-provider`). + * `payment-manager`/`payment-connector`; adds `knowledge-base` and + * `capacity-provider`). Datasets are deliberately out of scope for status. */ export type DeployableResource = | "runtime" @@ -198,7 +198,6 @@ export type DeployableResource = | "policy-engine" | "policy" | "config-bundle" - | "dataset" | "payment" | "capacity-provider"; From b8bbd2b0ca5aac3e8cf960178c356aab6bd46cdf Mon Sep 17 00:00:00 2001 From: jariy17 Date: Tue, 1 Sep 2026 21:14:11 +0000 Subject: [PATCH 03/15] refactor(project): resolve every deployed id in one exhaustive switch findDeployedResourceId only knew CloudFormation exports, so its two exceptions leaked outward: payment matched by OutputKey in an early return, and credential -- which is never a stack output at all -- was branched on by the caller. "Where does this id come from" lived in three places. Fold all fourteen types into one resolveResourceId switch that takes both sources (the stack and deployed-state's credential ARNs). The `never` default makes a new DeployableResource a compile error instead of a resource that silently vanishes from `project status`. Renamed off find* because it no longer only searches the stack. Export name literals are unchanged -- each was verified against a real stack, so they are deliberately not derived from the resourceType. --- src/core/project/backends/cdk.ts | 107 ++++++++++++++++++------------- 1 file changed, 61 insertions(+), 46 deletions(-) diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 28dac8aa2..1bd64c1e8 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -84,46 +84,68 @@ function toCdkId(name: string): string { return name.replace(/_/g, ""); } -// The exportName parts (after the stack name) for every type whose deployed id is -// a CloudFormation export. credential + payment are excluded on purpose — credential -// comes from deployed-state, payment matches by OutputKey below. `satisfies Record` -// makes this exhaustive: adding a DeployableResource without a row here is a compile -// error, so a new type can never silently resolve to "not found". -type CfnOutputResource = Exclude; - -const EXPORT_PARTS = { - runtime: (name) => [name, "RuntimeId"], - harness: (name) => ["Harness", name, "Id"], - memory: (name) => ["Memory", name, "Id"], - "knowledge-base": (name) => ["KnowledgeBase", name, "Id"], - evaluator: (name) => ["Evaluator", name, "Id"], - "online-eval": (name) => ["OnlineEval", name, "Id"], - gateway: (name) => ["Gateway", name, "Id"], - "gateway-target": (name) => ["GatewayTarget", name, "Id"], - "policy-engine": (name) => ["PolicyEngine", name, "Id"], - policy: (name, parent) => ["Policy", parent ?? "", name, "Id"], - "config-bundle": (name) => ["ConfigBundle", name, "Id"], - // Output arrives with aws/agentcore-l3-cdk-constructs#336; resolves once it ships. - "capacity-provider": (name) => ["CapacityProvider", name, "Id"], -} satisfies Record string[]>; - -function findDeployedResourceId( - stack: Stack, - input: { resourceType: Exclude; name: string; parent?: string }, +// resolveResourceId maps a declared resource to its deployed physical id. Most ids +// are CloudFormation exports under a deterministic ExportName; payment and credential +// come from elsewhere, so every source lives in this one switch. The `never` default +// makes a new DeployableResource a compile error rather than a resource that silently +// vanishes from `project status`. +function resolveResourceId( + sources: { + stack: Stack; + /** deployed-state credential ARNs by name — credentials are created imperatively, never by CFN. */ + credentialArns: Record; + }, + input: { resourceType: DeployableResource; name: string; parent?: string }, ): string | undefined { - if (!stack.StackName) return undefined; - // TODO(cdk): the CLI's payment CfnOutputs (assets/cdk/lib/cdk-stack.ts) set no - // ExportName, so match by their predictable OutputKey. Once they export a name, - // fold payment into EXPORT_PARTS and delete this branch. - if (input.resourceType === "payment") { - const key = `Payment${toCdkId(input.name)}ManagerId`; - return stack.Outputs?.find((output) => output.OutputKey === key)?.OutputValue; + const { stack, credentialArns } = sources; + const { resourceType, name, parent } = input; + + const byExportName = (...parts: string[]) => { + if (!stack.StackName) return undefined; + const want = cfnExportName(stack.StackName, ...parts); + return stack.Outputs?.find((output) => output.ExportName === want)?.OutputValue; + }; + + switch (resourceType) { + case "runtime": + return byExportName(name, "RuntimeId"); + case "harness": + return byExportName("Harness", name, "Id"); + case "memory": + return byExportName("Memory", name, "Id"); + case "knowledge-base": + return byExportName("KnowledgeBase", name, "Id"); + case "evaluator": + return byExportName("Evaluator", name, "Id"); + case "online-eval": + return byExportName("OnlineEval", name, "Id"); + case "gateway": + return byExportName("Gateway", name, "Id"); + case "gateway-target": + return byExportName("GatewayTarget", name, "Id"); + case "policy-engine": + return byExportName("PolicyEngine", name, "Id"); + case "policy": + return byExportName("Policy", parent ?? "", name, "Id"); + case "config-bundle": + return byExportName("ConfigBundle", name, "Id"); + // Output arrives with aws/agentcore-l3-cdk-constructs#336; resolves once it ships. + case "capacity-provider": + return byExportName("CapacityProvider", name, "Id"); + // TODO(cdk): the CLI's payment CfnOutputs (assets/cdk/lib/cdk-stack.ts) set no + // ExportName, so match by their predictable OutputKey. Once they export a name, + // fold payment in above and delete this case. + case "payment": + return stack.Outputs?.find( + (output) => output.OutputKey === `Payment${toCdkId(name)}ManagerId`, + )?.OutputValue; + case "credential": + return credentialArns[name]?.credentialProviderArn; + default: { + const unhandled: never = resourceType; + return unhandled; + } } - const want = cfnExportName( - stack.StackName, - ...EXPORT_PARTS[input.resourceType](input.name, input.parent), - ); - return stack.Outputs?.find((output) => output.ExportName === want)?.OutputValue; } export type CdkBackendConfig = { @@ -480,14 +502,7 @@ export class CdkBackend implements ProjectBackend { ]; return declared.flatMap((r) => { - const id = - r.resourceType === "credential" - ? credentialArns[r.name]?.credentialProviderArn - : findDeployedResourceId(stack, { - resourceType: r.resourceType, - name: r.name, - parent: r.parent, - }); + const id = resolveResourceId({ stack, credentialArns }, r); return id ? [{ ...r, id, target }] : []; }); } From 15e0c1ba84aa308988ab065498ab1747fa359357 Mon Sep 17 00:00:00 2001 From: jariy17 Date: Tue, 1 Sep 2026 22:20:59 +0000 Subject: [PATCH 04/15] refactor(project): inline deployed-id resolution into its only caller resolveResourceId had a single call site and took a two-source parameter object (stack + credentialArns) purely to reach values that were already locals there. Closing over them instead removes the parameter object. --- src/core/project/backends/cdk.ts | 120 ++++++++++++++----------------- 1 file changed, 54 insertions(+), 66 deletions(-) diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 1bd64c1e8..a854d0658 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -1,6 +1,5 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; -import type { Stack } from "@aws-sdk/client-cloudformation"; import { MalformedServiceResponseError, ProjectStateError } from "../../../errors/errors"; import type { DeployableResource, @@ -84,70 +83,6 @@ function toCdkId(name: string): string { return name.replace(/_/g, ""); } -// resolveResourceId maps a declared resource to its deployed physical id. Most ids -// are CloudFormation exports under a deterministic ExportName; payment and credential -// come from elsewhere, so every source lives in this one switch. The `never` default -// makes a new DeployableResource a compile error rather than a resource that silently -// vanishes from `project status`. -function resolveResourceId( - sources: { - stack: Stack; - /** deployed-state credential ARNs by name — credentials are created imperatively, never by CFN. */ - credentialArns: Record; - }, - input: { resourceType: DeployableResource; name: string; parent?: string }, -): string | undefined { - const { stack, credentialArns } = sources; - const { resourceType, name, parent } = input; - - const byExportName = (...parts: string[]) => { - if (!stack.StackName) return undefined; - const want = cfnExportName(stack.StackName, ...parts); - return stack.Outputs?.find((output) => output.ExportName === want)?.OutputValue; - }; - - switch (resourceType) { - case "runtime": - return byExportName(name, "RuntimeId"); - case "harness": - return byExportName("Harness", name, "Id"); - case "memory": - return byExportName("Memory", name, "Id"); - case "knowledge-base": - return byExportName("KnowledgeBase", name, "Id"); - case "evaluator": - return byExportName("Evaluator", name, "Id"); - case "online-eval": - return byExportName("OnlineEval", name, "Id"); - case "gateway": - return byExportName("Gateway", name, "Id"); - case "gateway-target": - return byExportName("GatewayTarget", name, "Id"); - case "policy-engine": - return byExportName("PolicyEngine", name, "Id"); - case "policy": - return byExportName("Policy", parent ?? "", name, "Id"); - case "config-bundle": - return byExportName("ConfigBundle", name, "Id"); - // Output arrives with aws/agentcore-l3-cdk-constructs#336; resolves once it ships. - case "capacity-provider": - return byExportName("CapacityProvider", name, "Id"); - // TODO(cdk): the CLI's payment CfnOutputs (assets/cdk/lib/cdk-stack.ts) set no - // ExportName, so match by their predictable OutputKey. Once they export a name, - // fold payment in above and delete this case. - case "payment": - return stack.Outputs?.find( - (output) => output.OutputKey === `Payment${toCdkId(name)}ManagerId`, - )?.OutputValue; - case "credential": - return credentialArns[name]?.credentialProviderArn; - default: { - const unhandled: never = resourceType; - return unhandled; - } - } -} - export type CdkBackendConfig = { logger: Logger; runner?: ProcessRunner; @@ -467,6 +402,59 @@ export class CdkBackend implements ProjectBackend { const credentialArns = deployedState.targets[target.name]?.resources?.credentials ?? {}; type Declared = { resourceType: DeployableResource; name: string; parent?: string }; + + const byExportName = (...parts: string[]) => { + if (!stack.StackName) return undefined; + const want = cfnExportName(stack.StackName, ...parts); + return stack.Outputs?.find((output) => output.ExportName === want)?.OutputValue; + }; + + // Where each resource type's deployed id comes from. Keeping every source in one + // switch means the `never` default turns a new DeployableResource into a compile + // error, rather than a resource that silently vanishes from `project status`. + const idOf = ({ resourceType, name, parent }: Declared): string | undefined => { + switch (resourceType) { + case "runtime": + return byExportName(name, "RuntimeId"); + case "harness": + return byExportName("Harness", name, "Id"); + case "memory": + return byExportName("Memory", name, "Id"); + case "knowledge-base": + return byExportName("KnowledgeBase", name, "Id"); + case "evaluator": + return byExportName("Evaluator", name, "Id"); + case "online-eval": + return byExportName("OnlineEval", name, "Id"); + case "gateway": + return byExportName("Gateway", name, "Id"); + case "gateway-target": + return byExportName("GatewayTarget", name, "Id"); + case "policy-engine": + return byExportName("PolicyEngine", name, "Id"); + case "policy": + return byExportName("Policy", parent ?? "", name, "Id"); + case "config-bundle": + return byExportName("ConfigBundle", name, "Id"); + // Output arrives with aws/agentcore-l3-cdk-constructs#336; resolves once it ships. + case "capacity-provider": + return byExportName("CapacityProvider", name, "Id"); + // TODO(cdk): the CLI's payment CfnOutputs (assets/cdk/lib/cdk-stack.ts) set no + // ExportName, so match by their predictable OutputKey. Once they export a name, + // fold payment in above and delete this case. + case "payment": + return stack.Outputs?.find( + (output) => output.OutputKey === `Payment${toCdkId(name)}ManagerId`, + )?.OutputValue; + case "credential": + return credentialArns[name]?.credentialProviderArn; + default: { + const unhandled: never = resourceType; + return unhandled; + } + } + }; + const declared: Declared[] = [ ...spec.runtimes.map(({ name }) => ({ resourceType: "runtime" as const, name })), ...spec.harnesses.map(({ name }) => ({ resourceType: "harness" as const, name })), @@ -502,7 +490,7 @@ export class CdkBackend implements ProjectBackend { ]; return declared.flatMap((r) => { - const id = resolveResourceId({ stack, credentialArns }, r); + const id = idOf(r); return id ? [{ ...r, id, target }] : []; }); } From 95df8a3ab4dff3b0923193e3a67c537961170dec Mon Sep 17 00:00:00 2001 From: jariy17 Date: Tue, 1 Sep 2026 23:56:07 +0000 Subject: [PATCH 05/15] feat(project): report deployed resources by ARN instead of bare id project status surfaces these to customers, where an ARN is the useful identifier. gateway-target stays on its id: AgentCoreMcp exports no -Arn for it yet. --- src/core/project/backends/cdk.test.ts | 80 +++++++++++++++++---------- src/core/project/backends/cdk.ts | 29 +++++----- 2 files changed, 68 insertions(+), 41 deletions(-) diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index a0d4b796a..30c97bce1 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -26,6 +26,8 @@ const TARGET = { } as const; const STACK_ARN = "arn:aws:cloudformation:us-east-1:111122223333:stack/AgentCore-example-default/abc"; +/** ARN prefix the CDK's `-Arn` exports carry, so fixtures assert ARNs and not bare ids. */ +const ARN = `arn:aws:bedrock-agentcore:${TARGET.region}:${TARGET.account}`; const json = new FsReadWriteJson({ logger: createSilentLogger() }); /** A template holding only what CDK adds itself, as an empty project synthesizes. */ @@ -841,12 +843,12 @@ describe("CdkBackend.resolveDeployedResources", () => { StackStatus: "CREATE_COMPLETE", Outputs: [ { - ExportName: "AgentCore-example-default-checkout-agent-RuntimeId", - OutputValue: "checkout_agent-AbCdEf1234", + ExportName: "AgentCore-example-default-checkout-agent-RuntimeArn", + OutputValue: `${ARN}:runtime/checkout_agent-AbCdEf1234`, }, { - ExportName: "AgentCore-example-default-Harness-support-agent-Id", - OutputValue: "support_agent-AbCdEf1234", + ExportName: "AgentCore-example-default-Harness-support-agent-Arn", + OutputValue: `${ARN}:harness/support_agent-AbCdEf1234`, }, ], }, @@ -858,13 +860,13 @@ describe("CdkBackend.resolveDeployedResources", () => { { resourceType: "runtime", name: "checkout_agent", - id: "checkout_agent-AbCdEf1234", + id: `${ARN}:runtime/checkout_agent-AbCdEf1234`, target: TARGET, }, { resourceType: "harness", name: "support_agent", - id: "support_agent-AbCdEf1234", + id: `${ARN}:harness/support_agent-AbCdEf1234`, target: TARGET, }, ]); @@ -963,19 +965,20 @@ describe("CdkBackend.resolveDeployedResources", () => { CreationTime: new Date(0), StackStatus: "CREATE_COMPLETE", Outputs: [ - out(`${S}-web-RuntimeId`, "web-1"), - out(`${S}-Harness-chat-Id`, "chat-1"), - out(`${S}-Memory-user-mem-Id`, "mem-1"), - out(`${S}-KnowledgeBase-kb-Id`, "kb-1"), - out(`${S}-Evaluator-ev-Id`, "ev-1"), - out(`${S}-OnlineEval-oe-Id`, "oe-1"), - out(`${S}-Gateway-gw-Id`, "gw-1"), + 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}:knowledge-base/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`), + // gateway-target is the one type the CDK exports by id only (no -Arn). out(`${S}-GatewayTarget-tgt-Id`, "tgt-1"), - out(`${S}-PolicyEngine-pe-Id`, "pe-1"), - out(`${S}-Policy-pe-pol-Id`, "pol-1"), - out(`${S}-ConfigBundle-cb-Id`, "cb-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`), // payment: no ExportName — only a predictable OutputKey - { OutputKey: "PaymentpayManagerId", OutputValue: "pay-1" }, + { OutputKey: "PaymentpayManagerArn", OutputValue: `${ARN}:payment-manager/pay-1` }, ], }, }); @@ -983,19 +986,40 @@ describe("CdkBackend.resolveDeployedResources", () => { const resources = await subject.backend.resolveDeployedResources(input, { target: TARGET }); expect(resources).toEqual([ - { resourceType: "runtime", name: "web", id: "web-1", target: TARGET }, - { resourceType: "harness", name: "chat", id: "chat-1", target: TARGET }, - { resourceType: "memory", name: "user_mem", id: "mem-1", target: TARGET }, - { resourceType: "knowledge-base", name: "kb", id: "kb-1", target: TARGET }, + { resourceType: "runtime", name: "web", id: `${ARN}:runtime/web-1`, target: TARGET }, + { resourceType: "harness", name: "chat", id: `${ARN}:harness/chat-1`, target: TARGET }, + { resourceType: "memory", name: "user_mem", id: `${ARN}:memory/mem-1`, target: TARGET }, + { + resourceType: "knowledge-base", + name: "kb", + id: `${ARN}:knowledge-base/kb-1`, + target: TARGET, + }, { resourceType: "credential", name: "cred", id: "arn:aws:cred/cred", target: TARGET }, - { resourceType: "evaluator", name: "ev", id: "ev-1", target: TARGET }, - { resourceType: "online-eval", name: "oe", id: "oe-1", target: TARGET }, - { resourceType: "gateway", name: "gw", id: "gw-1", target: TARGET }, + { resourceType: "evaluator", name: "ev", id: `${ARN}:evaluator/ev-1`, target: TARGET }, + { resourceType: "online-eval", name: "oe", id: `${ARN}:online-eval/oe-1`, target: TARGET }, + { resourceType: "gateway", name: "gw", id: `${ARN}:gateway/gw-1`, target: TARGET }, { resourceType: "gateway-target", name: "tgt", parent: "gw", id: "tgt-1", target: TARGET }, - { resourceType: "policy-engine", name: "pe", id: "pe-1", target: TARGET }, - { resourceType: "policy", name: "pol", parent: "pe", id: "pol-1", target: TARGET }, - { resourceType: "config-bundle", name: "cb", id: "cb-1", target: TARGET }, - { resourceType: "payment", name: "pay", id: "pay-1", target: TARGET }, + { + resourceType: "policy-engine", + name: "pe", + id: `${ARN}:policy-engine/pe-1`, + target: TARGET, + }, + { + resourceType: "policy", + name: "pol", + parent: "pe", + id: `${ARN}:policy/pol-1`, + target: TARGET, + }, + { + resourceType: "config-bundle", + name: "cb", + id: `${ARN}:config-bundle/cb-1`, + target: TARGET, + }, + { resourceType: "payment", name: "pay", id: `${ARN}:payment-manager/pay-1`, target: TARGET }, ]); expect(subject.stackReads).toHaveLength(1); }); diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index a854d0658..2308eb619 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -409,42 +409,45 @@ export class CdkBackend implements ProjectBackend { return stack.Outputs?.find((output) => output.ExportName === want)?.OutputValue; }; - // Where each resource type's deployed id comes from. Keeping every source in one + // Where each resource type's deployed ARN comes from. Keeping every source in one // switch means the `never` default turns a new DeployableResource into a compile // error, rather than a resource that silently vanishes from `project status`. const idOf = ({ resourceType, name, parent }: Declared): string | undefined => { switch (resourceType) { case "runtime": - return byExportName(name, "RuntimeId"); + return byExportName(name, "RuntimeArn"); case "harness": - return byExportName("Harness", name, "Id"); + return byExportName("Harness", name, "Arn"); case "memory": - return byExportName("Memory", name, "Id"); + return byExportName("Memory", name, "Arn"); case "knowledge-base": - return byExportName("KnowledgeBase", name, "Id"); + return byExportName("KnowledgeBase", name, "Arn"); case "evaluator": - return byExportName("Evaluator", name, "Id"); + return byExportName("Evaluator", name, "Arn"); case "online-eval": - return byExportName("OnlineEval", name, "Id"); + return byExportName("OnlineEval", name, "Arn"); case "gateway": - return byExportName("Gateway", name, "Id"); + return byExportName("Gateway", name, "Arn"); + // TODO(cdk): AgentCoreMcp exports GatewayTarget--Id but no -Arn, so this + // is the one resource reported by id. Once the construct exports an Arn, switch + // to byExportName("GatewayTarget", name, "Arn") and this case joins the rest. case "gateway-target": return byExportName("GatewayTarget", name, "Id"); case "policy-engine": - return byExportName("PolicyEngine", name, "Id"); + return byExportName("PolicyEngine", name, "Arn"); case "policy": - return byExportName("Policy", parent ?? "", name, "Id"); + return byExportName("Policy", parent ?? "", name, "Arn"); case "config-bundle": - return byExportName("ConfigBundle", name, "Id"); + return byExportName("ConfigBundle", name, "Arn"); // Output arrives with aws/agentcore-l3-cdk-constructs#336; resolves once it ships. case "capacity-provider": - return byExportName("CapacityProvider", name, "Id"); + return byExportName("CapacityProvider", name, "Arn"); // TODO(cdk): the CLI's payment CfnOutputs (assets/cdk/lib/cdk-stack.ts) set no // ExportName, so match by their predictable OutputKey. Once they export a name, // fold payment in above and delete this case. case "payment": return stack.Outputs?.find( - (output) => output.OutputKey === `Payment${toCdkId(name)}ManagerId`, + (output) => output.OutputKey === `Payment${toCdkId(name)}ManagerArn`, )?.OutputValue; case "credential": return credentialArns[name]?.credentialProviderArn; From 838d8efa4b6888a9458375fb3f3e342099b7c6cd Mon Sep 17 00:00:00 2001 From: jariy17 Date: Wed, 2 Sep 2026 13:10:03 +0000 Subject: [PATCH 06/15] chore(project): drop code comments from deployed-resource resolution --- src/core/project/backends/cdk.test.ts | 5 ----- src/core/project/backends/cdk.ts | 20 -------------------- src/core/project/backends/types.ts | 1 - src/core/project/manager.tsx | 3 --- src/handlers/project/invoke/screen.tsx | 2 -- src/handlers/project/types.ts | 15 --------------- 6 files changed, 46 deletions(-) diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index 30c97bce1..d36af8edd 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -26,7 +26,6 @@ const TARGET = { } as const; const STACK_ARN = "arn:aws:cloudformation:us-east-1:111122223333:stack/AgentCore-example-default/abc"; -/** ARN prefix the CDK's `-Arn` exports carry, so fixtures assert ARNs and not bare ids. */ const ARN = `arn:aws:bedrock-agentcore:${TARGET.region}:${TARGET.account}`; const json = new FsReadWriteJson({ logger: createSilentLogger() }); @@ -937,8 +936,6 @@ describe("CdkBackend.resolveDeployedResources", () => { test("resolves every deployed resource type: exports, payment OutputKey, credential from state, nested parents, underscores", async () => { const input = await project(); - // The resolver only reads names (and nested target/policy names), so a - // hand-shaped spec is enough here — schema validity is tested elsewhere. input.spec = { ...input.spec, runtimes: [{ name: "web" }], @@ -972,12 +969,10 @@ describe("CdkBackend.resolveDeployedResources", () => { 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`), - // gateway-target is the one type the CDK exports by id only (no -Arn). 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`), - // payment: no ExportName — only a predictable OutputKey { OutputKey: "PaymentpayManagerArn", OutputValue: `${ARN}:payment-manager/pay-1` }, ], }, diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 2308eb619..ab40f3171 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -69,16 +69,10 @@ type StackDescriber = typeof describeStack; */ const MAX_ERROR_OUTPUT_LINES = 20; -// Mirrors @aws/agentcore-cdk's exportName() (its src/cdk/logical-ids.ts): join the -// parts with "-" after turning "_" into "-" and dropping anything outside -// [A-Za-z0-9:-]. Replicated rather than imported because that package is a CDK -// construct library, not a CLI dependency — this is the source-of-truth format. function cfnExportName(...parts: string[]): string { return parts.map((part) => part.replace(/_/g, "-").replace(/[^a-zA-Z0-9:-]/g, "")).join("-"); } -// toCdkId mirrors the payment CfnOutput logical-id construction in the CLI's own -// cdk-stack.ts (assets/cdk/lib/cdk-stack.ts): underscores stripped, rest kept. function toCdkId(name: string): string { return name.replace(/_/g, ""); } @@ -397,8 +391,6 @@ export class CdkBackend implements ProjectBackend { } const { spec } = project; - // Credential ids are never stack outputs — they're created imperatively and - // recorded in deployed-state. Read them from the state we already loaded. const credentialArns = deployedState.targets[target.name]?.resources?.credentials ?? {}; type Declared = { resourceType: DeployableResource; name: string; parent?: string }; @@ -409,9 +401,6 @@ export class CdkBackend implements ProjectBackend { return stack.Outputs?.find((output) => output.ExportName === want)?.OutputValue; }; - // Where each resource type's deployed ARN comes from. Keeping every source in one - // switch means the `never` default turns a new DeployableResource into a compile - // error, rather than a resource that silently vanishes from `project status`. const idOf = ({ resourceType, name, parent }: Declared): string | undefined => { switch (resourceType) { case "runtime": @@ -428,9 +417,6 @@ export class CdkBackend implements ProjectBackend { return byExportName("OnlineEval", name, "Arn"); case "gateway": return byExportName("Gateway", name, "Arn"); - // TODO(cdk): AgentCoreMcp exports GatewayTarget--Id but no -Arn, so this - // is the one resource reported by id. Once the construct exports an Arn, switch - // to byExportName("GatewayTarget", name, "Arn") and this case joins the rest. case "gateway-target": return byExportName("GatewayTarget", name, "Id"); case "policy-engine": @@ -439,12 +425,8 @@ export class CdkBackend implements ProjectBackend { return byExportName("Policy", parent ?? "", name, "Arn"); case "config-bundle": return byExportName("ConfigBundle", name, "Arn"); - // Output arrives with aws/agentcore-l3-cdk-constructs#336; resolves once it ships. case "capacity-provider": return byExportName("CapacityProvider", name, "Arn"); - // TODO(cdk): the CLI's payment CfnOutputs (assets/cdk/lib/cdk-stack.ts) set no - // ExportName, so match by their predictable OutputKey. Once they export a name, - // fold payment in above and delete this case. case "payment": return stack.Outputs?.find( (output) => output.OutputKey === `Payment${toCdkId(name)}ManagerArn`, @@ -487,9 +469,7 @@ export class CdkBackend implements ProjectBackend { })), ]), ...spec.configBundles.map(({ name }) => ({ resourceType: "config-bundle" as const, name })), - // datasets are intentionally excluded — out of scope for project status. ...(spec.payments ?? []).map(({ name }) => ({ resourceType: "payment" as const, name })), - // capacity-provider has no spec array yet — arrives with l3-cdk-constructs#336. ]; return declared.flatMap((r) => { diff --git a/src/core/project/backends/types.ts b/src/core/project/backends/types.ts index 0ac642596..67c0af850 100644 --- a/src/core/project/backends/types.ts +++ b/src/core/project/backends/types.ts @@ -16,7 +16,6 @@ export type DeployBackendInput = { export type ResolveDeployedResourcesBackendInput = { target: AwsDeploymentTarget; - /** When true, an undeployed target yields [] instead of throwing. */ allowMissing?: boolean; }; diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 1f9633542..5002594da 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -937,9 +937,6 @@ export class FsProjectManager implements ProjectManager { const resource = resolved.resources.find( ({ resourceType, name }) => resourceType === input.resourceType && name === input.name, ); - // The declared target wins over the copy on the item: the manager resolved it - // from aws-targets.json, and both invoke handlers pin the AWS region off this - // value, so trusting a backend's echo would let it redirect the call. if (resource) return { ...resource, target: resolved.target }; const label = input.resourceType === "runtime" ? "Runtime" : "Harness"; diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx index 1d7501250..411a84ab8 100644 --- a/src/handlers/project/invoke/screen.tsx +++ b/src/handlers/project/invoke/screen.tsx @@ -83,8 +83,6 @@ export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) { const rows = useMemo( () => - // resolveDeployedResources now returns every deployed resource type, but only - // runtimes and harnesses are invokable — drop the rest so they aren't listed. (deployed?.resources ?? []) .filter( (r): r is typeof r & { resourceType: "runtime" | "harness" } => diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 09f70bbf7..910beedd9 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -168,23 +168,9 @@ export type ResolveDeployedResourceInput = { export type ResolveDeployedResourcesInput = { target: string; - /** - * When true, an undeployed target resolves to an empty resource list instead - * of throwing. `project status` wants to render every declared resource as - * local-only rather than error out before the stack exists; deploy/remove - * still want the hard failure, so it stays opt-in. - */ allowMissing?: boolean; }; -/** - * Every project resource type that can be surfaced as deployed. Broader than - * {@link ProjectInvokableResource} (runtime/harness) because `project status` - * reports the whole stack, not just what you can invoke. Not derived from - * {@link ProjectResource}: the deployed vocabulary differs (e.g. `payment`, not - * `payment-manager`/`payment-connector`; adds `knowledge-base` and - * `capacity-provider`). Datasets are deliberately out of scope for status. - */ export type DeployableResource = | "runtime" | "harness" @@ -205,7 +191,6 @@ export type ResolvedDeployedResource = { resourceType: DeployableResource; name: string; id: string; - /** Owner name for nested types: policy → engine, gateway-target → gateway. */ parent?: string; target: AwsDeploymentTarget; }; From 457bc72469e4cbdd98c96ee67a6b2f126279d63d Mon Sep 17 00:00:00 2001 From: jariy17 Date: Wed, 2 Sep 2026 00:02:34 +0000 Subject: [PATCH 07/15] feat(project): implement project status Returns a JSON report of every declared resource and whether the target's stack holds it. Children nest under their owner rather than carrying a parent name, and identifier is omitted (not null) when undeployed. pending-removal is out of scope: the resolver enumerates the spec, so a resource deleted from the spec but still in the stack is not discoverable. --- src/handlers/project/index.ts | 6 +- src/handlers/project/project.test.ts | 9 +- src/handlers/project/status/index.test.ts | 211 ++++++++++++++++++++++ src/handlers/project/status/index.ts | Bin 390 -> 4608 bytes 4 files changed, 221 insertions(+), 5 deletions(-) create mode 100644 src/handlers/project/status/index.test.ts diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 7547fe6d8..0e1078e2b 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/project.test.ts b/src/handlers/project/project.test.ts index 669723379..ed2a60085 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -24,10 +24,11 @@ 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/); - }); +// status is wrapped in withProject, so it must refuse to run outside one rather +// than report an empty project. Its output is covered in status/index.test.ts. +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..877b43ce9 --- /dev/null +++ b/src/handlers/project/status/index.test.ts @@ -0,0 +1,211 @@ +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 { ResolvedDeployedResource } 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}`; + +/** + * A ProjectBackend whose resolver reports exactly `deployed`. Stubbing the backend + * rather than the manager keeps the real FsProjectManager in the path, so target + * resolution and withProject run for real. + */ +function fakeBackend(deployed: Omit[]) { + const targets: AwsDeploymentTarget[] = []; + const backend: ProjectBackend = { + async *build() {}, + async *deploy() { + yield { message: "unused by these tests" }; + return { outputs: {} }; + }, + async resolveDeployedResources(_project, input) { + targets.push(input.target); + return deployed.map((resource) => ({ ...resource, target: input.target })); + }, + }; + return { targets, backend }; +} + +function testStatusCommand(deployed: Omit[] = []) { + 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 })), + ); +}); + +/** + * Scaffolds a real project, merges `spec` into its agentcore.json, and cds into it. + * withProject parses that file for real, so the fragments must satisfy the schema. + * Memories and policy engines are enough: one is flat, the other nests. + */ +async function inProject( + subject: ReturnType, + spec: Record = {}, +): 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 memory = (name: string) => ({ name, eventExpiryDuration: 30 }); +const policy = (name: string) => ({ name, statement: "permit(principal, action, resource);" }); +/** `project create` scaffolds a harness named after the project; it is never deployed here. */ +const SCAFFOLDED_HARNESS = { + resourceType: "harness", + name: "orders", + deploymentState: "local-only", +}; + +describe("project status handler", () => { + test("reports deployed resources by ARN, nesting children under their owner", async () => { + const subject = testStatusCommand([ + { resourceType: "memory", name: "shortTerm", id: `${ARN}:memory/shortTerm-1` }, + { resourceType: "policy-engine", name: "guards", id: `${ARN}:policy-engine/guards-1` }, + { resourceType: "policy", name: "noPii", parent: "guards", id: `${ARN}:policy/noPii-1` }, + ]); + await inProject(subject, { + memories: [memory("shortTerm")], + policyEngines: [ + { name: "guards", policies: [policy("noPii")] }, + // An engine with no policies must omit `children` rather than emit []. + { name: "empty", policies: [] }, + ], + }); + + await subject.run(); + + expect(subject.json()).toEqual({ + projectName: "orders", + target: "default", + region: "us-east-1", + resources: [ + SCAFFOLDED_HARNESS, + { + resourceType: "memory", + name: "shortTerm", + deploymentState: "deployed", + identifier: `${ARN}:memory/shortTerm-1`, + }, + { + resourceType: "policy-engine", + name: "guards", + deploymentState: "deployed", + identifier: `${ARN}:policy-engine/guards-1`, + children: [ + { + resourceType: "policy", + name: "noPii", + deploymentState: "deployed", + identifier: `${ARN}:policy/noPii-1`, + }, + ], + }, + { resourceType: "policy-engine", name: "empty", deploymentState: "local-only" }, + ], + }); + }); + + // A local-only resource has no ARN, so `identifier: null` would invite callers to + // render one. Omitting the key keeps "not deployed" unambiguous. + test("omits identifier for resources the stack does not hold", async () => { + const subject = testStatusCommand([ + { resourceType: "memory", name: "shortTerm", id: `${ARN}:memory/shortTerm-1` }, + ]); + await inProject(subject, { memories: [memory("shortTerm"), memory("longTerm")] }); + + await subject.run(); + + expect(subject.json().resources).toEqual([ + SCAFFOLDED_HARNESS, + { + resourceType: "memory", + name: "shortTerm", + deploymentState: "deployed", + identifier: `${ARN}:memory/shortTerm-1`, + }, + { resourceType: "memory", name: "longTerm", deploymentState: "local-only" }, + ]); + }); + + // allowMissing: never having deployed is the normal state before the first deploy, + // so status must describe the project rather than fail. + test("reports every resource local-only when nothing is deployed", async () => { + const subject = testStatusCommand([]); + await inProject(subject, { memories: [memory("shortTerm")] }); + + await subject.run(); + + expect(subject.json()).toEqual({ + projectName: "orders", + target: "default", + region: "us-east-1", + resources: [ + SCAFFOLDED_HARNESS, + { resourceType: "memory", name: "shortTerm", deploymentState: "local-only" }, + ], + }); + }); + + 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 d5b9ad7664a47302f1fdbfa564fc2e65cb3e6802..a6b38ede3c589582abd8f97525636878ecd4aaf5 100644 GIT binary patch literal 4608 zcma)A+in{-5bd+SVxR`76iDlb0DVyE22SF(Xq&WfUG%{S5_*@C7_Uf`MmO5x=XLRYo4Q(Dww zIi+9Y)}Lg1U#&Hbmv*S)M`!f8)RnZ-KDfmdHb> zDXeW-W&_t@Pm8sj2WPc(Ug;%?8bPjFa-wyxZpdu4V~KSErNo{yL!0D~y2~ny_-IjNV;|ndlA~MXJzC6+ms2TS=q=%*+^pK4%=61yyRXKs_KF zA!DZ@kvJX@H26(dEk{Axf&)+*?L1wh+)ilxf0$2q5TB;7cj!&%zMI?j>>277QbC5xG&FP`ponQchjD8oo{)uouC>~bn~1>%w(CN9M7GAO0MW8?HpG>XwrqU_3u z51-W!sIhntG9$zdCDj!hA~x9iv4~kUo)nc_h^F@A2l#jrk4Kaq-o*D+Alf+z*Xj~* z`VWzF9m5tVi@iHOrmga;1V1qczw}m`=4-((yZ4&y%bK;m0TDLp2g`F%AzWwz1@R#Ae@lc3gs!LWszJ? z^o|n;tX?LfJx`?W#z}740Zxh7=OJ-i5%VR7kao}kx7tSLZ+8H32lNh~69BfQS7OuW zVfH(C#y!s!YkVKu5dBLKz}R-*m~*FO3(i#j9m{vZkE(jp=hvS7SUFv*i-nr#JO(D6 z{x$T@bCjc%bdVm6&fy5io>XSgeW|#f5u(0U=ZGek`<3iNkn!pcgq|Gz9477Fj4=U9 zJaF*xIG#8q^BtQFK}mi64^dx-W=C!#OQuT|l23|q1$XuvKG$8yJ{3zW9y~XE?!mDE zqXLnrq4l3~pGN~%$&6ZRQKQ5IPX}EIAQTU@YJ-_T`o`)sCv;APazXRIYS6_Ym7$4U zdeP{TY4RS1U96|wjydPuH<70>Uxc>$?c{!23k49#!h(50@Bw@Fm(%l5+mE5;#LVq8 zu9(2%pyAv7uJn{X(a|yBVO$H5yXRw1i_y(~UcjCXAWXI6P(1_3W1pN-aw^wP%99j< z*qHhh4MmsoFBD#Sj+O>P&A<=z)9lEIFz)oM*L97eD8@TGZU=)8jt|7{x&mO~>~x1( zQA@o~p4W`+3CaBw( z%XfjNkSXI)`aX&%Pm=R__exj{IqvZGByeE+TCUL{4(|@%+XI`gwb|CPTFU3>Cy8M1 z``<~;t{`?m6^58F)rDK$MT4|TWBQ!NStJssBaE4sZzfUy z5dKt9`$p2;H2ICroET)jV;jugx0UWOFQx@dGZ*oq%eSKB7lG*2I^5fMvfIpR%q{hl z*23cu7)H|M*%EG1AePt{;@H6j8H%FO0t3=g%PJc3}BB*I3N(`UydT6!QB1CyT>ip&R~A6y%rMaM6{#51=bCp9-UuOv0awWuh+NTF6CttdZNK}k Date: Wed, 2 Sep 2026 13:06:24 +0000 Subject: [PATCH 08/15] fix(project): report status for a target the project has not declared project create leaves aws-targets.json empty and only project deploy provisions the default target, so status errored on a freshly created project instead of reporting that nothing is deployed yet. --- src/core/project/manager.tsx | 45 ++++++++++++++-------- src/handlers/project/invoke/screen.tsx | 2 +- src/handlers/project/status/index.test.ts | 21 +++++++++- src/handlers/project/status/index.ts | Bin 4608 -> 4692 bytes src/handlers/project/types.ts | 2 +- 5 files changed, 50 insertions(+), 20 deletions(-) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 5002594da..817512988 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -933,11 +933,13 @@ export class FsProjectManager implements ProjectManager { project: Project, input: ResolveDeployedResourceInput, ): Promise { - const resolved = await this.resolveDeployedResources(project, { target: input.target }); - const resource = resolved.resources.find( + const { resources, target } = await this.resolveDeployedResources(project, { + target: input.target, + }); + const resource = resources.find( ({ resourceType, name }) => resourceType === input.resourceType && name === input.name, ); - if (resource) return { ...resource, target: resolved.target }; + if (target && resource) return { ...resource, target }; const label = input.resourceType === "runtime" ? "Runtime" : "Harness"; throw new ProjectStateError( @@ -950,7 +952,11 @@ export class FsProjectManager implements ProjectManager { project: Project, input: ResolveDeployedResourcesInput, ): Promise { - const target = await this.resolveExistingTarget(project, input.target); + const target = await this.resolveTarget(project, input.target, { + undeclaredDefaultIsUndeployed: input.allowMissing === true, + }); + if (!target) return { resources: [] }; + const resources = await this.backendFor(project).resolveDeployedResources(project, { target, allowMissing: input.allowMissing, @@ -958,19 +964,29 @@ export class FsProjectManager implements ProjectManager { return { resources, target }; } - private async resolveExistingTarget( + private async resolveTarget( project: Project, name: string, - ): Promise { + options: { undeclaredDefaultIsUndeployed: boolean } = { undeclaredDefaultIsUndeployed: false }, + ): Promise { const targetsPath = join(project.rootPath, "agentcore", "aws-targets.json"); - if (!existsSync(targetsPath)) { + const targetsExist = existsSync(targetsPath); + const targets = targetsExist + ? await this.json.read(targetsPath, AwsDeploymentTargetsSchema) + : []; + + const target = targets.find((candidate) => candidate.name === name); + if (target) return target; + + if (options.undeclaredDefaultIsUndeployed && name === DEFAULT_TARGET_NAME) return undefined; + + if (!targetsExist) { throw new ProjectStateError( `No deployment targets are configured for project '${project.name}'. ` + `Add ${targetsPath}, for example:\n\n${TARGETS_EXAMPLE}`, ); } - const targets = await this.json.read(targetsPath, AwsDeploymentTargetsSchema); if (targets.length === 0) { throw new ProjectStateError( `No deployment targets are configured for project '${project.name}'. ` + @@ -978,15 +994,10 @@ export class FsProjectManager implements ProjectManager { ); } - const target = targets.find((candidate) => candidate.name === name); - if (!target) { - throw new ProjectStateError( - `Project '${project.name}' has no deployment target named '${name}'. ` + - `${targetsPath} defines: ${targets.map(({ name }) => name).join(", ")}.`, - ); - } - - return target; + throw new ProjectStateError( + `Project '${project.name}' has no deployment target named '${name}'. ` + + `${targetsPath} defines: ${targets.map(({ name }) => name).join(", ")}.`, + ); } /** diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx index 411a84ab8..54e1351ac 100644 --- a/src/handlers/project/invoke/screen.tsx +++ b/src/handlers/project/invoke/screen.tsx @@ -110,7 +110,7 @@ export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) { ); const select = (row: ProjectInvokableRow) => { - if (!deployed) return; + if (!deployed?.target) return; setDestination({ resourceType: row.resourceType, id: row.id, diff --git a/src/handlers/project/status/index.test.ts b/src/handlers/project/status/index.test.ts index 877b43ce9..eb38f20d3 100644 --- a/src/handlers/project/status/index.test.ts +++ b/src/handlers/project/status/index.test.ts @@ -83,13 +83,14 @@ afterEach(async () => { 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)); + 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 })); @@ -195,6 +196,24 @@ describe("project status handler", () => { }); }); + test("reports local-only against a freshly created project that declares no targets", async () => { + const subject = testStatusCommand([]); + await inProject(subject, { memories: [memory("shortTerm")] }, []); + + await subject.run(["--region", "us-west-2"]); + + expect(subject.targets).toEqual([]); + expect(subject.json()).toEqual({ + projectName: "orders", + target: "default", + region: "us-west-2", + resources: [ + SCAFFOLDED_HARNESS, + { resourceType: "memory", name: "shortTerm", deploymentState: "local-only" }, + ], + }); + }); + test("--target selects another target, and an unknown one is rejected", async () => { const subject = testStatusCommand([]); await inProject(subject); diff --git a/src/handlers/project/status/index.ts b/src/handlers/project/status/index.ts index a6b38ede3c589582abd8f97525636878ecd4aaf5..6e401f2384eb434895f476d4619a02710b18fdba 100644 GIT binary patch delta 108 zcmZorxuP=RmRz+$P-=Q+ex7$~r9!PjT2X$kf|8z|J`iQ6Ru*r(aEx8dUN0{(H&wyj xULh?fF}+x?B(W$xwPf;a4n-wp0XI`~^I;BKP5>!wCI|ok delta 34 qcmcbj(x5Wo)@CNgJ?yM{d5O8Hldo_nvg;M4rf25oZ5HRW Date: Wed, 2 Sep 2026 13:14:01 +0000 Subject: [PATCH 09/15] chore(project): drop code comments and use a text key separator The status resource key joined its parts with NUL bytes, which made git treat the handler as a binary file and hid it from review diffs. --- src/handlers/project/project.test.ts | 2 -- src/handlers/project/status/index.test.ts | 16 ---------------- src/handlers/project/status/index.ts | Bin 4692 -> 3655 bytes 3 files changed, 18 deletions(-) diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index ed2a60085..8cbd07982 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -24,8 +24,6 @@ async function run(args: string[], opts?: { core?: TestCoreClient; stdin?: strin return { io, core }; } -// status is wrapped in withProject, so it must refuse to run outside one rather -// than report an empty project. Its output is covered in status/index.test.ts. test("project status requires an AgentCore project", async () => { await inTempDirectory(); await expect(run(["status"])).rejects.toThrow(/No AgentCore project found/); diff --git a/src/handlers/project/status/index.test.ts b/src/handlers/project/status/index.test.ts index eb38f20d3..3dc3c7e2e 100644 --- a/src/handlers/project/status/index.test.ts +++ b/src/handlers/project/status/index.test.ts @@ -26,11 +26,6 @@ const STAGING_TARGET: AwsDeploymentTarget = { const TARGETS = [DEFAULT_TARGET, STAGING_TARGET]; const ARN = `arn:aws:bedrock-agentcore:${DEFAULT_TARGET.region}:${DEFAULT_TARGET.account}`; -/** - * A ProjectBackend whose resolver reports exactly `deployed`. Stubbing the backend - * rather than the manager keeps the real FsProjectManager in the path, so target - * resolution and withProject run for real. - */ function fakeBackend(deployed: Omit[]) { const targets: AwsDeploymentTarget[] = []; const backend: ProjectBackend = { @@ -75,11 +70,6 @@ afterEach(async () => { ); }); -/** - * Scaffolds a real project, merges `spec` into its agentcore.json, and cds into it. - * withProject parses that file for real, so the fragments must satisfy the schema. - * Memories and policy engines are enough: one is flat, the other nests. - */ async function inProject( subject: ReturnType, spec: Record = {}, @@ -99,7 +89,6 @@ async function inProject( const memory = (name: string) => ({ name, eventExpiryDuration: 30 }); const policy = (name: string) => ({ name, statement: "permit(principal, action, resource);" }); -/** `project create` scaffolds a harness named after the project; it is never deployed here. */ const SCAFFOLDED_HARNESS = { resourceType: "harness", name: "orders", @@ -117,7 +106,6 @@ describe("project status handler", () => { memories: [memory("shortTerm")], policyEngines: [ { name: "guards", policies: [policy("noPii")] }, - // An engine with no policies must omit `children` rather than emit []. { name: "empty", policies: [] }, ], }); @@ -155,8 +143,6 @@ describe("project status handler", () => { }); }); - // A local-only resource has no ARN, so `identifier: null` would invite callers to - // render one. Omitting the key keeps "not deployed" unambiguous. test("omits identifier for resources the stack does not hold", async () => { const subject = testStatusCommand([ { resourceType: "memory", name: "shortTerm", id: `${ARN}:memory/shortTerm-1` }, @@ -177,8 +163,6 @@ describe("project status handler", () => { ]); }); - // allowMissing: never having deployed is the normal state before the first deploy, - // so status must describe the project rather than fail. test("reports every resource local-only when nothing is deployed", async () => { const subject = testStatusCommand([]); await inProject(subject, { memories: [memory("shortTerm")] }); diff --git a/src/handlers/project/status/index.ts b/src/handlers/project/status/index.ts index 6e401f2384eb434895f476d4619a02710b18fdba..ca733ba152ce3d75445d867572d0409519e42171 100644 GIT binary patch delta 68 zcmV-K0K5OxB*z@ElL51p0x1EL76fAvFC=?#VRB_|bRa)JAR;1tFSCjSoB@+(1~0Qf a2Uh~KX9_6=v*8be0h6>5LFTele)ZLlTR*qHE_$cq!JtnAdS;k%mRdN@ z$+5S!lT5#x&by!_M~^bP@n9;kGqR=#93 z38hh#yCCAp7L==^;FK%075ibr6q6y);!Tq_reK#reDUo{esgW>eOKMy9&K)(tS#@Z zf9dUx$JD4$9yt4!WuVyWs@6CHJ%?b#&PHkrN>i%NLbJv9jh$0m7h2oqiSp^tTR`k0 za8CqkB0ZjCdL8cAN#y4#e!aw5U}ouo=%qO*gRTVHDu|u4F4pkt!{=1hmb9~(jRWvZ z6E3WSK~Qa>oDW^XZ25cR&6SnO4uw8W;K^4khnSd3iY#RLANNHzNl~3)_}6{W4`nD z^J{~vkAdoCT8L!PsR-PNmas!=mNuk?U#pP@X+5Nw2(pa$O54ObT7q5Ao6eT=>mPfM zpqPsQo)}O(Rwl%?vSLtVv&Ekq4_^^MQHn}P z71|LZ=Gar0jE04YLqHmbqFWA075~&64o>Lx_DJT(BWYm*SIgjUZ*T9Qe|mkdf71U8 DAQpeU From 8aa07bec57b6a2a6a2486c546a3cc31f3abbf2c6 Mon Sep 17 00:00:00 2001 From: jariy17 Date: Wed, 2 Sep 2026 15:17:24 +0000 Subject: [PATCH 10/15] refactor(project): read credential ARNs at the point of use --- src/core/project/backends/cdk.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index ab40f3171..367dae871 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -391,7 +391,6 @@ export class CdkBackend implements ProjectBackend { } const { spec } = project; - const credentialArns = deployedState.targets[target.name]?.resources?.credentials ?? {}; type Declared = { resourceType: DeployableResource; name: string; parent?: string }; @@ -432,7 +431,8 @@ export class CdkBackend implements ProjectBackend { (output) => output.OutputKey === `Payment${toCdkId(name)}ManagerArn`, )?.OutputValue; case "credential": - return credentialArns[name]?.credentialProviderArn; + return deployedState.targets[target.name]?.resources?.credentials?.[name] + ?.credentialProviderArn; default: { const unhandled: never = resourceType; return unhandled; From 1cc8636bf3212a9bb07bd6fd947c279e3da44657 Mon Sep 17 00:00:00 2001 From: jariy17 Date: Wed, 2 Sep 2026 15:20:10 +0000 Subject: [PATCH 11/15] refactor(project): name the idOf parameter --- src/core/project/backends/cdk.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 367dae871..d3b843737 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -400,7 +400,8 @@ export class CdkBackend implements ProjectBackend { return stack.Outputs?.find((output) => output.ExportName === want)?.OutputValue; }; - const idOf = ({ resourceType, name, parent }: Declared): string | undefined => { + const idOf = (declared: Declared): string | undefined => { + const { resourceType, name, parent } = declared; switch (resourceType) { case "runtime": return byExportName(name, "RuntimeArn"); From 169870ed6de5bd3af5f19d7608cbce856dd3ce69 Mon Sep 17 00:00:00 2001 From: jariy17 Date: Wed, 2 Sep 2026 16:08:27 +0000 Subject: [PATCH 12/15] refactor(project): inline the payment output-key sanitization --- src/core/project/backends/cdk.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index d3b843737..43f9679c5 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -69,14 +69,12 @@ type StackDescriber = typeof describeStack; */ const MAX_ERROR_OUTPUT_LINES = 20; +// CDK library constructs the ExportName using this shared function +// https://github.com/aws/agentcore-l3-cdk-constructs/blob/main/src/cdk/logical-ids.ts#L84 function cfnExportName(...parts: string[]): string { return parts.map((part) => part.replace(/_/g, "-").replace(/[^a-zA-Z0-9:-]/g, "")).join("-"); } -function toCdkId(name: string): string { - return name.replace(/_/g, ""); -} - export type CdkBackendConfig = { logger: Logger; runner?: ProcessRunner; @@ -422,16 +420,20 @@ export class CdkBackend implements ProjectBackend { case "policy-engine": return byExportName("PolicyEngine", name, "Arn"); case "policy": + // ExportName: -Policy---Arn return byExportName("Policy", parent ?? "", name, "Arn"); case "config-bundle": return byExportName("ConfigBundle", name, "Arn"); case "capacity-provider": return byExportName("CapacityProvider", name, "Arn"); case "payment": + // Payments doesn't set a ExportName so we search for OutputKey + // https://github.com/aws/agentcore-l3-cdk-constructs/blob/main/src/cdk/constructs/l3/AgentCorePayments.ts#L170-L172 return stack.Outputs?.find( - (output) => output.OutputKey === `Payment${toCdkId(name)}ManagerArn`, + (output) => output.OutputKey === `Payment${name.replace(/_/g, "")}ManagerArn`, )?.OutputValue; case "credential": + // Credential is created imperatively, it's arn is located within deployed-state.json return deployedState.targets[target.name]?.resources?.credentials?.[name] ?.credentialProviderArn; default: { From e3bd6deb788eea48ebd3fe93d9432ec055ebc51f Mon Sep 17 00:00:00 2001 From: jariy17 Date: Wed, 2 Sep 2026 16:23:20 +0000 Subject: [PATCH 13/15] refactor(project): drop allowMissing from deployed-resource resolution Also drops the unassignedTargets row from project status: nothing in the CLI can create an unassigned gateway target. --- src/core/project/backends/cdk.test.ts | 30 ---------------- src/core/project/backends/cdk.ts | 4 +-- src/core/project/backends/types.ts | 1 - src/core/project/manager.tsx | 42 ++++++++--------------- src/handlers/project/status/index.test.ts | 16 +++------ src/handlers/project/status/index.ts | 7 ++-- src/handlers/project/types.ts | 3 +- 7 files changed, 23 insertions(+), 80 deletions(-) diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index d36af8edd..6cbd7fc5f 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -1041,34 +1041,4 @@ describe("CdkBackend.resolveDeployedResources", () => { subject.backend.resolveDeployedResources(input, { target: TARGET }), ).resolves.toEqual([]); }); - - test("allowMissing returns [] instead of throwing when the target has no stack ARN", async () => { - const input = await project(); - const subject = harness({ describedStack: null }); - - await expect( - subject.backend.resolveDeployedResources(input, { target: TARGET, allowMissing: true }), - ).resolves.toEqual([]); - expect(subject.stackReads).toEqual([]); - }); - - test("allowMissing returns [] instead of throwing when the recorded stack is gone", async () => { - const input = await project(); - await updateTargetState(json, input.rootPath, TARGET.name, { stackArn: STACK_ARN }); - const subject = harness({ describedStack: null }); - - await expect( - subject.backend.resolveDeployedResources(input, { target: TARGET, allowMissing: true }), - ).resolves.toEqual([]); - }); - - test("allowMissing does not swallow a wrong-account error", async () => { - const input = await project(); - await updateTargetState(json, input.rootPath, TARGET.name, { stackArn: STACK_ARN }); - const subject = harness({ account: "999900001111" }); - - await expect( - subject.backend.resolveDeployedResources(input, { target: TARGET, allowMissing: true }), - ).rejects.toThrow(/expects AWS account 111122223333.*999900001111/s); - }); }); diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 43f9679c5..49d9be3d8 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -367,11 +367,10 @@ export class CdkBackend implements ProjectBackend { project: Project, input: ResolveDeployedResourcesBackendInput, ): Promise { - const { target, allowMissing } = input; + const { target } = input; const deployedState = await readDeployedState(this.json, project.rootPath); const stackArn = deployedState.targets[target.name]?.stackArn; if (!stackArn) { - if (allowMissing) return []; throw new ProjectStateError( `Project '${project.name}' is not deployed to target '${target.name}'. ` + `Run 'agentcore project deploy --target ${target.name}' first.`, @@ -381,7 +380,6 @@ export class CdkBackend implements ProjectBackend { const credentials = await this.credentialsForTarget(target); const stack = await this.describeStack(target.region, credentials, stackArn); if (!stack) { - if (allowMissing) return []; throw new ProjectStateError( `Project '${project.name}' is not deployed to target '${target.name}'. ` + `Run 'agentcore project deploy --target ${target.name}' first.`, diff --git a/src/core/project/backends/types.ts b/src/core/project/backends/types.ts index 67c0af850..dccb11da8 100644 --- a/src/core/project/backends/types.ts +++ b/src/core/project/backends/types.ts @@ -16,7 +16,6 @@ export type DeployBackendInput = { export type ResolveDeployedResourcesBackendInput = { target: AwsDeploymentTarget; - allowMissing?: boolean; }; /** Builds the deployable artifacts owned by a project's selected backend. */ diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 817512988..b0b746997 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -952,41 +952,24 @@ export class FsProjectManager implements ProjectManager { project: Project, input: ResolveDeployedResourcesInput, ): Promise { - const target = await this.resolveTarget(project, input.target, { - undeclaredDefaultIsUndeployed: input.allowMissing === true, - }); - if (!target) return { resources: [] }; - - const resources = await this.backendFor(project).resolveDeployedResources(project, { - target, - allowMissing: input.allowMissing, - }); + const target = await this.resolveExistingTarget(project, input.target); + const resources = await this.backendFor(project).resolveDeployedResources(project, { target }); return { resources, target }; } - private async resolveTarget( + private async resolveExistingTarget( project: Project, name: string, - options: { undeclaredDefaultIsUndeployed: boolean } = { undeclaredDefaultIsUndeployed: false }, - ): Promise { + ): Promise { const targetsPath = join(project.rootPath, "agentcore", "aws-targets.json"); - const targetsExist = existsSync(targetsPath); - const targets = targetsExist - ? await this.json.read(targetsPath, AwsDeploymentTargetsSchema) - : []; - - const target = targets.find((candidate) => candidate.name === name); - if (target) return target; - - if (options.undeclaredDefaultIsUndeployed && name === DEFAULT_TARGET_NAME) return undefined; - - if (!targetsExist) { + if (!existsSync(targetsPath)) { throw new ProjectStateError( `No deployment targets are configured for project '${project.name}'. ` + `Add ${targetsPath}, for example:\n\n${TARGETS_EXAMPLE}`, ); } + const targets = await this.json.read(targetsPath, AwsDeploymentTargetsSchema); if (targets.length === 0) { throw new ProjectStateError( `No deployment targets are configured for project '${project.name}'. ` + @@ -994,10 +977,15 @@ export class FsProjectManager implements ProjectManager { ); } - throw new ProjectStateError( - `Project '${project.name}' has no deployment target named '${name}'. ` + - `${targetsPath} defines: ${targets.map(({ name }) => name).join(", ")}.`, - ); + const target = targets.find((candidate) => candidate.name === name); + if (!target) { + throw new ProjectStateError( + `Project '${project.name}' has no deployment target named '${name}'. ` + + `${targetsPath} defines: ${targets.map(({ name }) => name).join(", ")}.`, + ); + } + + return target; } /** diff --git a/src/handlers/project/status/index.test.ts b/src/handlers/project/status/index.test.ts index 3dc3c7e2e..ddb41f3f1 100644 --- a/src/handlers/project/status/index.test.ts +++ b/src/handlers/project/status/index.test.ts @@ -180,22 +180,14 @@ describe("project status handler", () => { }); }); - test("reports local-only against a freshly created project that declares no targets", async () => { + test("rejects a freshly created project that declares no targets", async () => { const subject = testStatusCommand([]); await inProject(subject, { memories: [memory("shortTerm")] }, []); - await subject.run(["--region", "us-west-2"]); - + await expect(subject.run()).rejects.toThrow( + /No deployment targets are configured for project 'orders'/, + ); expect(subject.targets).toEqual([]); - expect(subject.json()).toEqual({ - projectName: "orders", - target: "default", - region: "us-west-2", - resources: [ - SCAFFOLDED_HARNESS, - { resourceType: "memory", name: "shortTerm", deploymentState: "local-only" }, - ], - }); }); test("--target selects another target, and an unknown one is rejected", async () => { diff --git a/src/handlers/project/status/index.ts b/src/handlers/project/status/index.ts index ca733ba15..9ba6b4f7d 100644 --- a/src/handlers/project/status/index.ts +++ b/src/handlers/project/status/index.ts @@ -2,7 +2,6 @@ import z from "zod"; import { DEFAULT_TARGET_NAME } from "../../../projectSchemas/aws-targets"; import { createHandler, flag, ProjectKey } from "../../../router"; import { JsonRendererKey } from "../../../tui"; -import { RegionKey } from "../../keys"; import type { DeployableResource, Project, ProjectManager } from "../types"; type StatusProjectHandlerConfig = { @@ -42,7 +41,6 @@ export const createStatusProjectHandler = (config: StatusProjectHandlerConfig) = const project = ctx.require(ProjectKey); const resolved = await config.projectManager.resolveDeployedResources(project, { target: flags.target, - allowMissing: true, }); const deployed = new Map( @@ -51,8 +49,8 @@ export const createStatusProjectHandler = (config: StatusProjectHandlerConfig) = const status: ProjectStatus = { projectName: project.name, - target: resolved.target?.name ?? flags.target, - region: resolved.target?.region ?? ctx.require(RegionKey), + target: resolved.target.name, + region: resolved.target.region, resources: describe(project, deployed), }; ctx.require(JsonRendererKey).renderJson(status); @@ -91,7 +89,6 @@ function describe(project: Project, deployed: Map): ResourceStat ), }), ), - ...(spec.unassignedTargets ?? []).map(({ name }) => row("gateway-target", name)), ...spec.policyEngines.map((engine) => row("policy-engine", engine.name, { children: (engine.policies ?? []).map(({ name }) => diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index a34a3cd1d..fd12d5ff9 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -168,7 +168,6 @@ export type ResolveDeployedResourceInput = { export type ResolveDeployedResourcesInput = { target: string; - allowMissing?: boolean; }; export type DeployableResource = @@ -197,7 +196,7 @@ export type ResolvedDeployedResource = { export type ResolvedDeployedResources = { resources: ResolvedDeployedResource[]; - target?: AwsDeploymentTarget; + target: AwsDeploymentTarget; }; export type Project = { From 0ddf02c05294bc71bc6a36050d1bc6dc5dc5d84f Mon Sep 17 00:00:00 2001 From: jariy17 Date: Wed, 2 Sep 2026 17:41:33 +0000 Subject: [PATCH 14/15] fix(project): restore project invoke and report local-only resources Widening the resolver to every declared resource switched its identifiers from bare IDs to ARNs, which broke 'project invoke': GetAgentRuntime and GetHarness both take an ID, and the service builds its IAM policy resource from whatever identifier it is handed, so an ARN failed as AccessDenied rather than a validation error. The backend now takes an identifier kind. resolveProjectResource asks for "id" for the invoke path; resolveProjectResources keeps ARNs for status. Renames resolveDeployedResource(s) to resolveProjectResource(s) and ResolvedDeployedResource to ResolvedProjectResource, and makes the plural return every declared resource as a discriminated union on deploymentState, so 'project status' can report local-only resources instead of silently omitting them. --- src/core/project/backends/cdk.test.ts | 115 ++++++++++++++---- src/core/project/backends/cdk.ts | 44 ++++--- src/core/project/backends/types.ts | 11 +- src/core/project/index.tsx | 2 +- src/core/project/manager.test.ts | 2 +- src/core/project/manager.tsx | 35 +++--- src/handlers/project/build/index.test.ts | 2 +- src/handlers/project/deploy/index.test.ts | 2 +- src/handlers/project/invoke/harness.tsx | 2 +- src/handlers/project/invoke/index.test.tsx | 10 +- .../project/invoke/invoke.screen.test.tsx | 63 ++++++++-- src/handlers/project/invoke/runtime.tsx | 2 +- src/handlers/project/invoke/screen.tsx | 17 ++- src/handlers/project/status/index.test.ts | 70 ++++++++--- src/handlers/project/status/index.ts | 75 ++++-------- src/handlers/project/types.ts | 32 ++--- 16 files changed, 311 insertions(+), 173 deletions(-) diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index 6cbd7fc5f..23a9e3cea 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -811,8 +811,8 @@ describe("CdkBackend.deploy", () => { }); }); -describe("CdkBackend.resolveDeployedResources", () => { - test("describes the stack once and returns only resources with deployed ID outputs", async () => { +describe("CdkBackend.resolveProjectResources", () => { + test("describes the stack once and reports every declared resource", async () => { const input = await project(); input.spec = ProjectSpecSchema.parse({ ...input.spec, @@ -853,18 +853,26 @@ describe("CdkBackend.resolveDeployedResources", () => { }, }); - const resources = await subject.backend.resolveDeployedResources(input, { target: TARGET }); + const resources = await subject.backend.resolveProjectResources(input, { target: TARGET }); expect(resources).toEqual([ { resourceType: "runtime", name: "checkout_agent", + deploymentState: "deployed", id: `${ARN}:runtime/checkout_agent-AbCdEf1234`, target: TARGET, }, + { + resourceType: "runtime", + name: "inventory", + deploymentState: "local-only", + target: TARGET, + }, { resourceType: "harness", name: "support_agent", + deploymentState: "deployed", id: `${ARN}:harness/support_agent-AbCdEf1234`, target: TARGET, }, @@ -877,7 +885,7 @@ describe("CdkBackend.resolveDeployedResources", () => { const subject = harness({ describedStack: null }); await expect( - subject.backend.resolveDeployedResources(input, { target: TARGET }), + subject.backend.resolveProjectResources(input, { target: TARGET }), ).rejects.toThrow(/not deployed.*project deploy --target default/s); expect(subject.stackReads).toEqual([]); expect(subject.accountCredentials).toEqual([]); @@ -889,12 +897,12 @@ describe("CdkBackend.resolveDeployedResources", () => { const subject = harness({ describedStack: null }); await expect( - subject.backend.resolveDeployedResources(input, { target: TARGET }), + subject.backend.resolveProjectResources(input, { target: TARGET }), ).rejects.toThrow(/not deployed.*project deploy --target default/s); expect(subject.stackReads[0]?.stackName).toBe(STACK_ARN); }); - test("omits configured resources that have no deployed ID output", async () => { + test("reports configured resources with no deployed ID output as local-only", async () => { const input = await project(); input.spec = ProjectSpecSchema.parse({ ...input.spec, @@ -919,8 +927,10 @@ describe("CdkBackend.resolveDeployedResources", () => { }); await expect( - subject.backend.resolveDeployedResources(input, { target: TARGET }), - ).resolves.toEqual([]); + subject.backend.resolveProjectResources(input, { target: TARGET }), + ).resolves.toEqual([ + { resourceType: "runtime", name: "checkout", deploymentState: "local-only", target: TARGET }, + ]); }); test("rejects the wrong account before reading CloudFormation", async () => { @@ -929,7 +939,7 @@ describe("CdkBackend.resolveDeployedResources", () => { const subject = harness({ account: "999900001111" }); await expect( - subject.backend.resolveDeployedResources(input, { target: TARGET }), + subject.backend.resolveProjectResources(input, { target: TARGET }), ).rejects.toThrow(/expects AWS account 111122223333.*999900001111/s); expect(subject.stackReads).toEqual([]); }); @@ -978,26 +988,77 @@ describe("CdkBackend.resolveDeployedResources", () => { }, }); - const resources = await subject.backend.resolveDeployedResources(input, { target: TARGET }); + const resources = await subject.backend.resolveProjectResources(input, { target: TARGET }); expect(resources).toEqual([ - { resourceType: "runtime", name: "web", id: `${ARN}:runtime/web-1`, target: TARGET }, - { resourceType: "harness", name: "chat", id: `${ARN}:harness/chat-1`, target: TARGET }, - { resourceType: "memory", name: "user_mem", id: `${ARN}:memory/mem-1`, target: TARGET }, + { + resourceType: "runtime", + name: "web", + deploymentState: "deployed", + id: `${ARN}:runtime/web-1`, + target: TARGET, + }, + { + resourceType: "harness", + name: "chat", + deploymentState: "deployed", + id: `${ARN}:harness/chat-1`, + target: TARGET, + }, + { + resourceType: "memory", + name: "user_mem", + deploymentState: "deployed", + id: `${ARN}:memory/mem-1`, + target: TARGET, + }, { resourceType: "knowledge-base", name: "kb", + deploymentState: "deployed", id: `${ARN}:knowledge-base/kb-1`, target: TARGET, }, - { resourceType: "credential", name: "cred", id: "arn:aws:cred/cred", target: TARGET }, - { resourceType: "evaluator", name: "ev", id: `${ARN}:evaluator/ev-1`, target: TARGET }, - { resourceType: "online-eval", name: "oe", id: `${ARN}:online-eval/oe-1`, target: TARGET }, - { resourceType: "gateway", name: "gw", id: `${ARN}:gateway/gw-1`, target: TARGET }, - { resourceType: "gateway-target", name: "tgt", parent: "gw", id: "tgt-1", target: TARGET }, + { + resourceType: "credential", + name: "cred", + deploymentState: "deployed", + id: "arn:aws:cred/cred", + target: TARGET, + }, + { + resourceType: "evaluator", + name: "ev", + deploymentState: "deployed", + id: `${ARN}:evaluator/ev-1`, + target: TARGET, + }, + { + resourceType: "online-eval", + name: "oe", + deploymentState: "deployed", + id: `${ARN}:online-eval/oe-1`, + target: TARGET, + }, + { + resourceType: "gateway", + name: "gw", + deploymentState: "deployed", + id: `${ARN}:gateway/gw-1`, + target: TARGET, + }, + { + resourceType: "gateway-target", + name: "tgt", + parent: "gw", + deploymentState: "deployed", + id: "tgt-1", + target: TARGET, + }, { resourceType: "policy-engine", name: "pe", + deploymentState: "deployed", id: `${ARN}:policy-engine/pe-1`, target: TARGET, }, @@ -1005,21 +1066,29 @@ describe("CdkBackend.resolveDeployedResources", () => { resourceType: "policy", name: "pol", parent: "pe", + deploymentState: "deployed", id: `${ARN}:policy/pol-1`, target: TARGET, }, { resourceType: "config-bundle", name: "cb", + deploymentState: "deployed", id: `${ARN}:config-bundle/cb-1`, target: TARGET, }, - { resourceType: "payment", name: "pay", id: `${ARN}:payment-manager/pay-1`, target: TARGET }, + { + resourceType: "payment", + name: "pay", + deploymentState: "deployed", + id: `${ARN}:payment-manager/pay-1`, + target: TARGET, + }, ]); expect(subject.stackReads).toHaveLength(1); }); - test("omits a declared non-runtime resource that has no deployed output", async () => { + test("reports a declared non-runtime resource with no deployed output as local-only", async () => { const input = await project(); input.spec = { ...input.spec, @@ -1038,7 +1107,9 @@ describe("CdkBackend.resolveDeployedResources", () => { }); await expect( - subject.backend.resolveDeployedResources(input, { target: TARGET }), - ).resolves.toEqual([]); + subject.backend.resolveProjectResources(input, { target: TARGET }), + ).resolves.toEqual([ + { resourceType: "memory", name: "mem", deploymentState: "local-only", target: TARGET }, + ]); }); }); diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 49d9be3d8..b6f60ed5f 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -6,7 +6,7 @@ import type { DeployResult, Project, ProjectEvent, - ResolvedDeployedResource, + ResolvedProjectResource, } from "../../../handlers/project/types"; import { createLineSplitter, @@ -22,7 +22,7 @@ import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; import type { DeployBackendInput, ProjectBackend, - ResolveDeployedResourcesBackendInput, + ResolveProjectResourcesBackendInput, } from "./types"; import { createCloudFormationClient } from "../../factories"; import type { CreateCloudFormationClient } from "../../types"; @@ -363,11 +363,11 @@ export class CdkBackend implements ProjectBackend { } } - public async resolveDeployedResources( + public async resolveProjectResources( project: Project, - input: ResolveDeployedResourcesBackendInput, - ): Promise { - const { target } = input; + input: ResolveProjectResourcesBackendInput, + ): Promise { + const { target, identifier = "arn" } = input; const deployedState = await readDeployedState(this.json, project.rootPath); const stackArn = deployedState.targets[target.name]?.stackArn; if (!stackArn) { @@ -396,39 +396,41 @@ export class CdkBackend implements ProjectBackend { return stack.Outputs?.find((output) => output.ExportName === want)?.OutputValue; }; + const suffix = identifier === "arn" ? "Arn" : "Id"; + const idOf = (declared: Declared): string | undefined => { const { resourceType, name, parent } = declared; switch (resourceType) { case "runtime": - return byExportName(name, "RuntimeArn"); + return byExportName(name, `Runtime${suffix}`); case "harness": - return byExportName("Harness", name, "Arn"); + return byExportName("Harness", name, suffix); case "memory": - return byExportName("Memory", name, "Arn"); + return byExportName("Memory", name, suffix); case "knowledge-base": - return byExportName("KnowledgeBase", name, "Arn"); + return byExportName("KnowledgeBase", name, suffix); case "evaluator": - return byExportName("Evaluator", name, "Arn"); + return byExportName("Evaluator", name, suffix); case "online-eval": - return byExportName("OnlineEval", name, "Arn"); + return byExportName("OnlineEval", name, suffix); case "gateway": - return byExportName("Gateway", name, "Arn"); + return byExportName("Gateway", name, suffix); case "gateway-target": return byExportName("GatewayTarget", name, "Id"); case "policy-engine": - return byExportName("PolicyEngine", name, "Arn"); + return byExportName("PolicyEngine", name, suffix); case "policy": // ExportName: -Policy---Arn - return byExportName("Policy", parent ?? "", name, "Arn"); + return byExportName("Policy", parent ?? "", name, suffix); case "config-bundle": - return byExportName("ConfigBundle", name, "Arn"); + return byExportName("ConfigBundle", name, suffix); case "capacity-provider": - return byExportName("CapacityProvider", name, "Arn"); + return byExportName("CapacityProvider", name, suffix); case "payment": // Payments doesn't set a ExportName so we search for OutputKey // https://github.com/aws/agentcore-l3-cdk-constructs/blob/main/src/cdk/constructs/l3/AgentCorePayments.ts#L170-L172 return stack.Outputs?.find( - (output) => output.OutputKey === `Payment${name.replace(/_/g, "")}ManagerArn`, + (output) => output.OutputKey === `Payment${name.replace(/_/g, "")}Manager${suffix}`, )?.OutputValue; case "credential": // Credential is created imperatively, it's arn is located within deployed-state.json @@ -473,9 +475,11 @@ export class CdkBackend implements ProjectBackend { ...(spec.payments ?? []).map(({ name }) => ({ resourceType: "payment" as const, name })), ]; - return declared.flatMap((r) => { + return declared.map((r) => { const id = idOf(r); - return id ? [{ ...r, id, target }] : []; + return id + ? { ...r, target, deploymentState: "deployed" as const, id } + : { ...r, target, deploymentState: "local-only" as const }; }); } diff --git a/src/core/project/backends/types.ts b/src/core/project/backends/types.ts index dccb11da8..36bb8e0d3 100644 --- a/src/core/project/backends/types.ts +++ b/src/core/project/backends/types.ts @@ -2,7 +2,7 @@ import type { DeployResult, Project, ProjectEvent, - ResolvedDeployedResource, + ResolvedProjectResource, TeardownConfirmationHandler, } from "../../../handlers/project/types"; import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; @@ -14,16 +14,17 @@ export type DeployBackendInput = { confirmTeardown: TeardownConfirmationHandler; }; -export type ResolveDeployedResourcesBackendInput = { +export type ResolveProjectResourcesBackendInput = { target: AwsDeploymentTarget; + identifier?: "arn" | "id"; }; /** Builds the deployable artifacts owned by a project's selected backend. */ export interface ProjectBackend { build(project: Project): AsyncGenerator; deploy(project: Project, input: DeployBackendInput): AsyncGenerator; - resolveDeployedResources( + resolveProjectResources( project: Project, - input: ResolveDeployedResourcesBackendInput, - ): Promise; + input: ResolveProjectResourcesBackendInput, + ): Promise; } diff --git a/src/core/project/index.tsx b/src/core/project/index.tsx index a543d8165..b70bc71b4 100644 --- a/src/core/project/index.tsx +++ b/src/core/project/index.tsx @@ -3,5 +3,5 @@ export { CdkBackend, type CdkBackendConfig } from "./backends/cdk"; export type { DeployBackendInput, ProjectBackend, - ResolveDeployedResourcesBackendInput, + ResolveProjectResourcesBackendInput, } from "./backends/types"; diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index a9aa4f9e1..2e2a2c63b 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -492,7 +492,7 @@ describe("FsProjectManager.deploy", () => { yield { type: "step" as const, message: "Backend deployment started" }; return { outputs: { RuntimeArn: "arn:runtime" } }; }, - async resolveDeployedResources() { + async resolveProjectResources() { return []; }, }; diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index b0b746997..133defbc5 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -8,10 +8,10 @@ import type { DeployResult, ExportHarnessInput, ExportHarnessResult, - ResolveDeployedResourceInput, - ResolveDeployedResourcesInput, - ResolvedDeployedResource, - ResolvedDeployedResources, + DeployedProjectResource, + ResolveProjectResourceInput, + ResolveProjectResourcesInput, + ResolvedProjectResources, ResolveProjectInput, ResolveTargetInput, Project, @@ -929,17 +929,22 @@ export class FsProjectManager implements ProjectManager { return targets.find((candidate) => candidate.name === input.target); } - public async resolveDeployedResource( + public async resolveProjectResource( project: Project, - input: ResolveDeployedResourceInput, - ): Promise { - const { resources, target } = await this.resolveDeployedResources(project, { - target: input.target, + input: ResolveProjectResourceInput, + ): Promise { + const target = await this.resolveExistingTarget(project, input.target); + const resources = await this.backendFor(project).resolveProjectResources(project, { + target, + identifier: "id", }); const resource = resources.find( - ({ resourceType, name }) => resourceType === input.resourceType && name === input.name, + (candidate) => + candidate.resourceType === input.resourceType && + candidate.name === input.name && + candidate.deploymentState === "deployed", ); - if (target && resource) return { ...resource, target }; + if (resource?.deploymentState === "deployed") return { ...resource, target }; const label = input.resourceType === "runtime" ? "Runtime" : "Harness"; throw new ProjectStateError( @@ -948,12 +953,12 @@ export class FsProjectManager implements ProjectManager { ); } - public async resolveDeployedResources( + public async resolveProjectResources( project: Project, - input: ResolveDeployedResourcesInput, - ): Promise { + input: ResolveProjectResourcesInput, + ): Promise { const target = await this.resolveExistingTarget(project, input.target); - const resources = await this.backendFor(project).resolveDeployedResources(project, { target }); + const resources = await this.backendFor(project).resolveProjectResources(project, { target }); return { resources, target }; } diff --git a/src/handlers/project/build/index.test.ts b/src/handlers/project/build/index.test.ts index 845ef8036..5128c3aab 100644 --- a/src/handlers/project/build/index.test.ts +++ b/src/handlers/project/build/index.test.ts @@ -29,7 +29,7 @@ function testBuildCommand(options: TestBuildOptions = {}) { deploy(): AsyncGenerator { throw new Error("deploy is not under test"); }, - async resolveDeployedResources() { + async resolveProjectResources() { return []; }, }; diff --git a/src/handlers/project/deploy/index.test.ts b/src/handlers/project/deploy/index.test.ts index 69b163cd3..9a02bd292 100644 --- a/src/handlers/project/deploy/index.test.ts +++ b/src/handlers/project/deploy/index.test.ts @@ -65,7 +65,7 @@ function fakeBackend( if (failure) throw failure; return result; }, - async resolveDeployedResources() { + async resolveProjectResources() { return []; }, }; diff --git a/src/handlers/project/invoke/harness.tsx b/src/handlers/project/invoke/harness.tsx index b11c33f0b..3bbee6506 100644 --- a/src/handlers/project/invoke/harness.tsx +++ b/src/handlers/project/invoke/harness.tsx @@ -35,7 +35,7 @@ export const createProjectInvokeHarnessHandler = ( handle: async (ctx, flags) => { const project = ctx.require(ProjectKey); const name = selectProjectResource(project, "harness", flags.name); - const deployed = await core.projectManager.resolveDeployedResource(project, { + const deployed = await core.projectManager.resolveProjectResource(project, { target: flags.target, resourceType: "harness", name, diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx index 91ce9dd4f..2960221ea 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -7,7 +7,7 @@ import type { GetAgentRuntimeResponse, GetHarnessResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; -import type { ProjectBackend, ResolveDeployedResourcesBackendInput } from "../../../core/project"; +import type { ProjectBackend, ResolveProjectResourcesBackendInput } from "../../../core/project"; import { ProjectSpecSchema } from "../../../projectSchemas/project"; import { ProjectKey, ValueContext, type Context } from "../../../router"; import { @@ -71,25 +71,27 @@ async function inProject(resources: { } function backend() { - const calls: ResolveDeployedResourcesBackendInput[] = []; + const calls: ResolveProjectResourcesBackendInput[] = []; const value: ProjectBackend = { async *build() {}, async *deploy() { yield* []; return { outputs: {} }; }, - async resolveDeployedResources(project, input) { + async resolveProjectResources(project, input) { calls.push(input); return [ ...project.spec.runtimes.map(({ name }) => ({ resourceType: "runtime" as const, name, + deploymentState: "deployed" as const, id: RUNTIME_ID, target: input.target, })), ...project.spec.harnesses.map(({ name }) => ({ resourceType: "harness" as const, name, + deploymentState: "deployed" as const, id: HARNESS_ID, target: input.target, })), @@ -164,7 +166,7 @@ describe("project invoke", () => { expect(request.contentType).toBe("application/custom+json"); expect(core.runtime.calls.at(-1)!.args[1]).toEqual({ region: TARGET.region }); expect(io.stdout()).toBe("runtime response"); - expect(resolved.calls).toEqual([{ target: TARGET }]); + expect(resolved.calls).toEqual([{ target: TARGET, identifier: "id" }]); }); test("invokes a named Harness with its existing prompt contract in the target region", async () => { diff --git a/src/handlers/project/invoke/invoke.screen.test.tsx b/src/handlers/project/invoke/invoke.screen.test.tsx index 30b6a49dc..e9eaf1970 100644 --- a/src/handlers/project/invoke/invoke.screen.test.tsx +++ b/src/handlers/project/invoke/invoke.screen.test.tsx @@ -7,7 +7,7 @@ import type { import { ProjectSpecSchema } from "../../../projectSchemas/project"; import { ProjectKey } from "../../../router"; import { cleanupScreens, renderScreen, TestCoreClient, waitForText } from "../../../testing"; -import type { Project, ResolvedDeployedResource } from "../types"; +import type { Project, ResolvedProjectResource } from "../types"; afterEach(cleanupScreens); @@ -46,20 +46,33 @@ function endpoint(name: string): AgentRuntimeEndpoint { const TARGET = { name: "default", account: "111122223333", region: "eu-west-1" } as const; -const DEPLOYED_RESOURCES: ResolvedDeployedResource[] = [ - { resourceType: "runtime", name: "checkout", id: "runtime-123", target: TARGET }, - { resourceType: "harness", name: "support", id: "harness-123", target: TARGET }, +const DEPLOYED_RESOURCES: ResolvedProjectResource[] = [ + { + resourceType: "runtime", + name: "checkout", + deploymentState: "deployed" as const, + id: "runtime-123", + target: TARGET, + }, + { + resourceType: "harness", + name: "support", + deploymentState: "deployed" as const, + id: "harness-123", + target: TARGET, + }, ]; -function core(resources: ResolvedDeployedResource[] = DEPLOYED_RESOURCES): TestCoreClient { +function core(resources: ResolvedProjectResource[] = DEPLOYED_RESOURCES): TestCoreClient { const value = new TestCoreClient(); - value.projectManager.resolveDeployedResource = async (_project, input) => ({ + value.projectManager.resolveProjectResource = async (_project, input) => ({ resourceType: input.resourceType, name: input.name, + deploymentState: "deployed" as const, id: input.resourceType === "runtime" ? "runtime-123" : "harness-123", target: TARGET, }); - value.projectManager.resolveDeployedResources = async () => ({ resources, target: TARGET }); + value.projectManager.resolveProjectResources = async () => ({ resources, target: TARGET }); value.runtime .setListEndpointsResponse({ runtimeEndpoints: [endpoint("DEFAULT")] }) .setGetResponse({ @@ -78,7 +91,39 @@ function core(resources: ResolvedDeployedResource[] = DEPLOYED_RESOURCES): TestC describe("project invoke picker", () => { test("lists only resources present in the deployed target", async () => { const screen = renderScreen("/agentcore/project/invoke", { - core: core([{ resourceType: "harness", name: "support", id: "harness-123", target: TARGET }]), + core: core([ + { + resourceType: "harness", + name: "support", + deploymentState: "deployed" as const, + id: "harness-123", + target: TARGET, + }, + ]), + withContext: (ctx) => ctx.withValue(ProjectKey, project), + }); + + await waitForText(screen.lastFrame, "support"); + expect(screen.lastFrame()).not.toContain("checkout"); + }); + + test("excludes a declared runtime that is not deployed", async () => { + const screen = renderScreen("/agentcore/project/invoke", { + core: core([ + { + resourceType: "harness", + name: "support", + deploymentState: "deployed", + id: "harness-123", + target: TARGET, + }, + { + resourceType: "runtime", + name: "checkout", + deploymentState: "local-only", + target: TARGET, + }, + ]), withContext: (ctx) => ctx.withValue(ProjectKey, project), }); @@ -100,7 +145,7 @@ describe("project invoke picker", () => { test("shows deployment errors without listing configured resources", async () => { const value = core(); - value.projectManager.resolveDeployedResources = async () => { + value.projectManager.resolveProjectResources = async () => { throw new Error("No deployment targets are configured for project 'orders'."); }; const screen = renderScreen("/agentcore/project/invoke", { diff --git a/src/handlers/project/invoke/runtime.tsx b/src/handlers/project/invoke/runtime.tsx index a5dcaab05..a80e6eecc 100644 --- a/src/handlers/project/invoke/runtime.tsx +++ b/src/handlers/project/invoke/runtime.tsx @@ -57,7 +57,7 @@ export const createProjectInvokeRuntimeHandler = ( handle: async (ctx, flags) => { const project = ctx.require(ProjectKey); const name = selectProjectResource(project, "runtime", flags.name); - const deployed = await core.projectManager.resolveDeployedResource(project, { + const deployed = await core.projectManager.resolveProjectResource(project, { target: flags.target, resourceType: "runtime", name, diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx index 54e1351ac..28a4fc5bb 100644 --- a/src/handlers/project/invoke/screen.tsx +++ b/src/handlers/project/invoke/screen.tsx @@ -10,7 +10,7 @@ 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 { Project, ResolvedProjectResources } from "../types"; type ProjectInvokableRow = Record & { resourceType: "runtime" | "harness"; @@ -35,7 +35,7 @@ type Destination = export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) { const navigate = useNavigate(); const [project, setProject] = useState(() => ctx.value(ProjectKey)); - const [deployed, setDeployed] = useState(); + const [deployed, setDeployed] = useState(); const [destination, setDestination] = useState(); const [error, setError] = useState(); @@ -69,7 +69,7 @@ export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) { if (!project) return; let active = true; void core.projectManager - .resolveDeployedResources(project, { target: "default" }) + .resolveProjectResources(project, { target: "default" }) .then((resolved) => { if (active) setDeployed(resolved); }) @@ -85,8 +85,15 @@ export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) { () => (deployed?.resources ?? []) .filter( - (r): r is typeof r & { resourceType: "runtime" | "harness" } => - r.resourceType === "runtime" || r.resourceType === "harness", + ( + r, + ): r is typeof r & { + resourceType: "runtime" | "harness"; + deploymentState: "deployed"; + id: string; + } => + (r.resourceType === "runtime" || r.resourceType === "harness") && + r.deploymentState === "deployed", ) .map((resource) => { if (resource.resourceType === "runtime") { diff --git a/src/handlers/project/status/index.test.ts b/src/handlers/project/status/index.test.ts index ddb41f3f1..903e86121 100644 --- a/src/handlers/project/status/index.test.ts +++ b/src/handlers/project/status/index.test.ts @@ -11,7 +11,7 @@ import { } from "../../../testing"; import type { ProjectBackend } from "../../../core/project"; import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; -import type { ResolvedDeployedResource } from "../types"; +import type { ResolvedProjectResource } from "../types"; const DEFAULT_TARGET: AwsDeploymentTarget = { name: "default", @@ -26,23 +26,29 @@ const STAGING_TARGET: AwsDeploymentTarget = { const TARGETS = [DEFAULT_TARGET, STAGING_TARGET]; const ARN = `arn:aws:bedrock-agentcore:${DEFAULT_TARGET.region}:${DEFAULT_TARGET.account}`; -function fakeBackend(deployed: Omit[]) { +type WithoutTarget = T extends unknown ? Omit : never; +type ResolvedRow = WithoutTarget; + +function fakeBackend(deployed: ResolvedRow[]) { const targets: AwsDeploymentTarget[] = []; const backend: ProjectBackend = { async *build() {}, async *deploy() { - yield { message: "unused by these tests" }; + yield { type: "step", message: "unused by these tests" }; return { outputs: {} }; }, - async resolveDeployedResources(_project, input) { + async resolveProjectResources(_project, input) { targets.push(input.target); - return deployed.map((resource) => ({ ...resource, target: input.target })); + return deployed.map((resource): ResolvedProjectResource => ({ + ...resource, + target: input.target, + })); }, }; return { targets, backend }; } -function testStatusCommand(deployed: Omit[] = []) { +function testStatusCommand(deployed: ResolvedRow[] = []) { const io = testIO(); const fake = fakeBackend(deployed); const root = createRootHandler(new TestCoreClient({ backends: { CDK: fake.backend } }), { @@ -87,20 +93,42 @@ async function inProject( process.chdir(projectRoot); } -const memory = (name: string) => ({ name, eventExpiryDuration: 30 }); -const policy = (name: string) => ({ name, statement: "permit(principal, action, resource);" }); -const SCAFFOLDED_HARNESS = { - resourceType: "harness", - name: "orders", +const deployed = ( + resourceType: ResolvedProjectResource["resourceType"], + name: string, + id: string, + parent?: string, +): ResolvedRow => ({ + resourceType, + name, + ...(parent ? { parent } : {}), + deploymentState: "deployed", + id, +}); + +const localOnly = ( + resourceType: ResolvedProjectResource["resourceType"], + name: string, + parent?: string, +): ResolvedRow => ({ + resourceType, + name, + ...(parent ? { parent } : {}), 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([ - { resourceType: "memory", name: "shortTerm", id: `${ARN}:memory/shortTerm-1` }, - { resourceType: "policy-engine", name: "guards", id: `${ARN}:policy-engine/guards-1` }, - { resourceType: "policy", name: "noPii", parent: "guards", id: `${ARN}:policy/noPii-1` }, + 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`, "guards"), + localOnly("policy-engine", "empty"), ]); await inProject(subject, { memories: [memory("shortTerm")], @@ -117,7 +145,7 @@ describe("project status handler", () => { target: "default", region: "us-east-1", resources: [ - SCAFFOLDED_HARNESS, + HARNESS_ROW, { resourceType: "memory", name: "shortTerm", @@ -145,14 +173,16 @@ describe("project status handler", () => { test("omits identifier for resources the stack does not hold", async () => { const subject = testStatusCommand([ - { resourceType: "memory", name: "shortTerm", id: `${ARN}:memory/shortTerm-1` }, + 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([ - SCAFFOLDED_HARNESS, + HARNESS_ROW, { resourceType: "memory", name: "shortTerm", @@ -164,7 +194,7 @@ describe("project status handler", () => { }); test("reports every resource local-only when nothing is deployed", async () => { - const subject = testStatusCommand([]); + const subject = testStatusCommand([HARNESS_ROW, localOnly("memory", "shortTerm")]); await inProject(subject, { memories: [memory("shortTerm")] }); await subject.run(); @@ -174,7 +204,7 @@ describe("project status handler", () => { target: "default", region: "us-east-1", resources: [ - SCAFFOLDED_HARNESS, + HARNESS_ROW, { resourceType: "memory", name: "shortTerm", deploymentState: "local-only" }, ], }); diff --git a/src/handlers/project/status/index.ts b/src/handlers/project/status/index.ts index 9ba6b4f7d..d5f938863 100644 --- a/src/handlers/project/status/index.ts +++ b/src/handlers/project/status/index.ts @@ -2,7 +2,7 @@ import z from "zod"; import { DEFAULT_TARGET_NAME } from "../../../projectSchemas/aws-targets"; import { createHandler, flag, ProjectKey } from "../../../router"; import { JsonRendererKey } from "../../../tui"; -import type { DeployableResource, Project, ProjectManager } from "../types"; +import type { DeployableResource, ProjectManager, ResolvedProjectResource } from "../types"; type StatusProjectHandlerConfig = { projectManager: ProjectManager; @@ -23,8 +23,25 @@ type ProjectStatus = { resources: ResourceStatus[]; }; -const key = (resourceType: DeployableResource, name: string, parent?: string) => - `${resourceType}/${parent ?? ""}/${name}`; +const toResourceStatus = (resource: ResolvedProjectResource): ResourceStatus => ({ + resourceType: resource.resourceType, + name: resource.name, + deploymentState: resource.deploymentState, + ...(resource.deploymentState === "deployed" ? { identifier: resource.id } : {}), +}); + +function nest(resources: ResolvedProjectResource[]): ResourceStatus[] { + const entries = resources.map((resource) => ({ resource, row: toResourceStatus(resource) })); + const top: ResourceStatus[] = []; + for (const { resource, row } of entries) { + const owner = resource.parent + ? entries.find(({ resource: other }) => !other.parent && other.name === resource.parent) + : undefined; + if (owner) (owner.row.children ??= []).push(row); + else top.push(row); + } + return top; +} export const createStatusProjectHandler = (config: StatusProjectHandlerConfig) => createHandler({ @@ -39,64 +56,16 @@ export const createStatusProjectHandler = (config: StatusProjectHandlerConfig) = ], handle: async (ctx, flags) => { const project = ctx.require(ProjectKey); - const resolved = await config.projectManager.resolveDeployedResources(project, { + const resolved = await config.projectManager.resolveProjectResources(project, { target: flags.target, }); - const deployed = new Map( - resolved.resources.map((r) => [key(r.resourceType, r.name, r.parent), r.id]), - ); - const status: ProjectStatus = { projectName: project.name, target: resolved.target.name, region: resolved.target.region, - resources: describe(project, deployed), + resources: nest(resolved.resources), }; ctx.require(JsonRendererKey).renderJson(status); }, }); - -function describe(project: Project, deployed: Map): ResourceStatus[] { - const row = ( - resourceType: DeployableResource, - name: string, - options: { parent?: string; children?: ResourceStatus[] } = {}, - ): ResourceStatus => { - const identifier = deployed.get(key(resourceType, name, options.parent)); - return { - resourceType, - name, - deploymentState: identifier ? "deployed" : "local-only", - ...(identifier ? { identifier } : {}), - ...(options.children?.length ? { children: options.children } : {}), - }; - }; - - const { spec } = project; - return [ - ...spec.runtimes.map(({ name }) => row("runtime", name)), - ...spec.harnesses.map(({ name }) => row("harness", name)), - ...spec.memories.map(({ name }) => row("memory", name)), - ...spec.knowledgeBases.map(({ name }) => row("knowledge-base", name)), - ...spec.credentials.map(({ name }) => row("credential", name)), - ...spec.evaluators.map(({ name }) => row("evaluator", name)), - ...spec.onlineEvalConfigs.map(({ name }) => row("online-eval", name)), - ...spec.agentCoreGateways.map((gateway) => - row("gateway", gateway.name, { - children: (gateway.targets ?? []).map(({ name }) => - row("gateway-target", name, { parent: gateway.name }), - ), - }), - ), - ...spec.policyEngines.map((engine) => - row("policy-engine", engine.name, { - children: (engine.policies ?? []).map(({ name }) => - row("policy", name, { parent: engine.name }), - ), - }), - ), - ...spec.configBundles.map(({ name }) => row("config-bundle", name)), - ...(spec.payments ?? []).map(({ name }) => row("payment", name)), - ]; -} diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index fd12d5ff9..8569a8148 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -160,13 +160,13 @@ export type ResolveTargetInput = { target: string; }; -export type ResolveDeployedResourceInput = { +export type ResolveProjectResourceInput = { target: string; resourceType: ProjectInvokableResource; name: string; }; -export type ResolveDeployedResourcesInput = { +export type ResolveProjectResourcesInput = { target: string; }; @@ -186,16 +186,20 @@ export type DeployableResource = | "payment" | "capacity-provider"; -export type ResolvedDeployedResource = { +export type ResolvedProjectResource = { resourceType: DeployableResource; name: string; - id: string; parent?: string; target: AwsDeploymentTarget; -}; +} & ({ deploymentState: "deployed"; id: string } | { deploymentState: "local-only" }); + +export type DeployedProjectResource = Extract< + ResolvedProjectResource, + { deploymentState: "deployed" } +>; -export type ResolvedDeployedResources = { - resources: ResolvedDeployedResource[]; +export type ResolvedProjectResources = { + resources: ResolvedProjectResource[]; target: AwsDeploymentTarget; }; @@ -382,16 +386,16 @@ export interface ProjectManager { resolve(input: ResolveProjectInput): Promise; /** Resolve a logical project resource to its deployed physical ID and target. */ - resolveDeployedResource( + resolveProjectResource( project: Project, - input: ResolveDeployedResourceInput, - ): Promise; + input: ResolveProjectResourceInput, + ): Promise; - /** Resolve every configured Runtime and Harness present in the deployed target stack. */ - resolveDeployedResources( + /** Resolve every configured project resource, deployed or not, for the target stack. */ + resolveProjectResources( project: Project, - input: ResolveDeployedResourcesInput, - ): Promise; + input: ResolveProjectResourcesInput, + ): Promise; /** Add a resource to an existing AgentCore project. */ addResource(project: Project, input: AddResourceInput): AsyncGenerator; From e93ac298ec5731dd1785f4da2c137d83fe88f406 Mon Sep 17 00:00:00 2001 From: jariy17 Date: Wed, 2 Sep 2026 21:08:00 +0000 Subject: [PATCH 15/15] fix(project): address review on project status resource resolution Resolve resources as ARNs only and convert at the invoke boundary. The identifier toggle defaulted to "arn", so the plural resolver silently handed ARNs to the TUI picker, which passed them as harnessId/agentRuntimeId and failed the service pattern. The toggle is gone; resourceIdFromArn reads the resource id where the data-plane APIs need one. Report resources rather than failing when a target has no stack. For status a missing or deleted stack simply means nothing is deployed, which is what local-only describes; deploy and remove still fail loudly. Report a freshly created project as undeployed. A project only gains its default target on first deploy, so status told the user to hand-write aws-targets.json with an example account id. The region the CLI resolved stands in, in memory, for the default target alone. Nest children under their owner's type. Names are unique per collection but not across them, so a gateway and a policy engine both named 'guards' filed the policy under the gateway. Rename payment to payment-manager and add payment-connector, matching the names project add and project remove already use, and drop capacity-provider: no capacityProviders field exists in the spec, so the case was unreachable. Point the payment output comments at the CLI's own cdk-stack.ts template, which emits those outputs without an exportName, rather than an L3 construct the generated project never instantiates. --- src/core/project/backends/cdk.test.ts | 29 +++++--- src/core/project/backends/cdk.ts | 74 +++++++++---------- src/core/project/backends/types.ts | 1 - src/core/project/manager.tsx | 37 ++++++++-- src/handlers/project/invoke/harness.tsx | 7 +- src/handlers/project/invoke/index.test.tsx | 6 +- .../project/invoke/invoke.screen.test.tsx | 13 ++-- src/handlers/project/invoke/runtime.tsx | 9 ++- src/handlers/project/invoke/screen.tsx | 63 ++++++++-------- src/handlers/project/status/index.test.ts | 45 +++++++++-- src/handlers/project/status/index.ts | 21 +++++- src/handlers/project/types.ts | 10 ++- src/handlers/utils.test.tsx | 26 ++++++- src/handlers/utils.tsx | 20 ++++- 14 files changed, 252 insertions(+), 109 deletions(-) diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index 23a9e3cea..55a406e2a 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -880,25 +880,25 @@ describe("CdkBackend.resolveProjectResources", () => { expect(subject.stackReads).toHaveLength(1); }); - test("fails without reading AWS when the target has no deployed stack ARN", async () => { + test("reports local-only without reading AWS when the target has no deployed stack ARN", async () => { const input = await project(); const subject = harness({ describedStack: null }); - await expect( - subject.backend.resolveProjectResources(input, { target: TARGET }), - ).rejects.toThrow(/not deployed.*project deploy --target default/s); + const resources = await subject.backend.resolveProjectResources(input, { target: TARGET }); + + expect(resources.every(({ deploymentState }) => deploymentState === "local-only")).toBe(true); expect(subject.stackReads).toEqual([]); expect(subject.accountCredentials).toEqual([]); }); - test("fails actionably when the recorded stack no longer exists", async () => { + test("reports local-only when the recorded stack no longer exists", async () => { const input = await project(); await updateTargetState(json, input.rootPath, TARGET.name, { stackArn: STACK_ARN }); const subject = harness({ describedStack: null }); - await expect( - subject.backend.resolveProjectResources(input, { target: TARGET }), - ).rejects.toThrow(/not deployed.*project deploy --target default/s); + const resources = await subject.backend.resolveProjectResources(input, { target: TARGET }); + + expect(resources.every(({ deploymentState }) => deploymentState === "local-only")).toBe(true); expect(subject.stackReads[0]?.stackName).toBe(STACK_ARN); }); @@ -958,7 +958,7 @@ describe("CdkBackend.resolveProjectResources", () => { agentCoreGateways: [{ name: "gw", targets: [{ name: "tgt" }] }], policyEngines: [{ name: "pe", policies: [{ name: "pol" }] }], configBundles: [{ name: "cb" }], - payments: [{ name: "pay" }], + payments: [{ name: "pay", connectors: [{ name: "wallet_one" }] }], } as unknown as typeof input.spec; await updateTargetState(json, input.rootPath, TARGET.name, { stackArn: STACK_ARN, @@ -984,6 +984,7 @@ describe("CdkBackend.resolveProjectResources", () => { out(`${S}-Policy-pe-pol-Arn`, `${ARN}:policy/pol-1`), out(`${S}-ConfigBundle-cb-Arn`, `${ARN}:config-bundle/cb-1`), { OutputKey: "PaymentpayManagerArn", OutputValue: `${ARN}:payment-manager/pay-1` }, + { OutputKey: "PaymentpaywalletoneConnectorId", OutputValue: "conn-1" }, ], }, }); @@ -1078,12 +1079,20 @@ describe("CdkBackend.resolveProjectResources", () => { target: TARGET, }, { - resourceType: "payment", + resourceType: "payment-manager", name: "pay", deploymentState: "deployed", id: `${ARN}:payment-manager/pay-1`, target: TARGET, }, + { + resourceType: "payment-connector", + name: "wallet_one", + parent: "pay", + deploymentState: "deployed", + id: "conn-1", + target: TARGET, + }, ]); expect(subject.stackReads).toHaveLength(1); }); diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index b6f60ed5f..bd232e6ba 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -367,71 +367,64 @@ export class CdkBackend implements ProjectBackend { project: Project, input: ResolveProjectResourcesBackendInput, ): Promise { - const { target, identifier = "arn" } = input; + const { target } = input; const deployedState = await readDeployedState(this.json, project.rootPath); const stackArn = deployedState.targets[target.name]?.stackArn; - if (!stackArn) { - throw new ProjectStateError( - `Project '${project.name}' is not deployed to target '${target.name}'. ` + - `Run 'agentcore project deploy --target ${target.name}' first.`, - ); - } - - const credentials = await this.credentialsForTarget(target); - const stack = await this.describeStack(target.region, credentials, stackArn); - if (!stack) { - throw new ProjectStateError( - `Project '${project.name}' is not deployed to target '${target.name}'. ` + - `Run 'agentcore project deploy --target ${target.name}' first.`, - ); - } + // A project with no stack has nothing deployed, which every resource below + // reports as local-only. Callers that require a deployment say so themselves. + const stack = stackArn + ? await this.describeStack(target.region, await this.credentialsForTarget(target), stackArn) + : undefined; const { spec } = project; type Declared = { resourceType: DeployableResource; name: string; parent?: string }; const byExportName = (...parts: string[]) => { - if (!stack.StackName) return undefined; + if (!stack?.StackName) return undefined; const want = cfnExportName(stack.StackName, ...parts); return stack.Outputs?.find((output) => output.ExportName === want)?.OutputValue; }; - const suffix = identifier === "arn" ? "Arn" : "Id"; + const byOutputKey = (key: string) => + stack?.Outputs?.find((output) => output.OutputKey === key)?.OutputValue; const idOf = (declared: Declared): string | undefined => { const { resourceType, name, parent } = declared; switch (resourceType) { case "runtime": - return byExportName(name, `Runtime${suffix}`); + return byExportName(name, "RuntimeArn"); case "harness": - return byExportName("Harness", name, suffix); + return byExportName("Harness", name, "Arn"); case "memory": - return byExportName("Memory", name, suffix); + return byExportName("Memory", name, "Arn"); case "knowledge-base": - return byExportName("KnowledgeBase", name, suffix); + return byExportName("KnowledgeBase", name, "Arn"); case "evaluator": - return byExportName("Evaluator", name, suffix); + return byExportName("Evaluator", name, "Arn"); case "online-eval": - return byExportName("OnlineEval", name, suffix); + return byExportName("OnlineEval", name, "Arn"); case "gateway": - return byExportName("Gateway", name, suffix); + return byExportName("Gateway", name, "Arn"); case "gateway-target": + // The L3 only exports an id for targets, never an ARN return byExportName("GatewayTarget", name, "Id"); case "policy-engine": - return byExportName("PolicyEngine", name, suffix); + return byExportName("PolicyEngine", name, "Arn"); case "policy": // ExportName: -Policy---Arn - return byExportName("Policy", parent ?? "", name, suffix); + return byExportName("Policy", parent ?? "", name, "Arn"); case "config-bundle": - return byExportName("ConfigBundle", name, suffix); - case "capacity-provider": - return byExportName("CapacityProvider", name, suffix); - case "payment": - // Payments doesn't set a ExportName so we search for OutputKey - // https://github.com/aws/agentcore-l3-cdk-constructs/blob/main/src/cdk/constructs/l3/AgentCorePayments.ts#L170-L172 - return stack.Outputs?.find( - (output) => output.OutputKey === `Payment${name.replace(/_/g, "")}Manager${suffix}`, - )?.OutputValue; + return byExportName("ConfigBundle", name, "Arn"); + case "payment-manager": + // The CLI's own template emits the payment outputs, and omits exportName, so + // we match the logical id it builds with toCdkId: src/assets/cdk/lib/cdk-stack.ts + return byOutputKey(`Payment${name.replace(/_/g, "")}ManagerArn`); + case "payment-connector": + // That same template only emits a ConnectorId for connectors, never an Arn + return byOutputKey( + `Payment${(parent ?? "").replace(/_/g, "")}${name.replace(/_/g, "")}ConnectorId`, + ); case "credential": // Credential is created imperatively, it's arn is located within deployed-state.json return deployedState.targets[target.name]?.resources?.credentials?.[name] @@ -472,7 +465,14 @@ export class CdkBackend implements ProjectBackend { })), ]), ...spec.configBundles.map(({ name }) => ({ resourceType: "config-bundle" as const, name })), - ...(spec.payments ?? []).map(({ name }) => ({ resourceType: "payment" as const, name })), + ...(spec.payments ?? []).flatMap((manager) => [ + { resourceType: "payment-manager" as const, name: manager.name }, + ...(manager.connectors ?? []).map(({ name }) => ({ + resourceType: "payment-connector" as const, + name, + parent: manager.name, + })), + ]), ]; return declared.map((r) => { diff --git a/src/core/project/backends/types.ts b/src/core/project/backends/types.ts index 36bb8e0d3..2a5ecc49d 100644 --- a/src/core/project/backends/types.ts +++ b/src/core/project/backends/types.ts @@ -16,7 +16,6 @@ export type DeployBackendInput = { export type ResolveProjectResourcesBackendInput = { target: AwsDeploymentTarget; - identifier?: "arn" | "id"; }; /** Builds the deployable artifacts owned by a project's selected backend. */ diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 133defbc5..a9597d5b1 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -934,10 +934,7 @@ export class FsProjectManager implements ProjectManager { input: ResolveProjectResourceInput, ): Promise { const target = await this.resolveExistingTarget(project, input.target); - const resources = await this.backendFor(project).resolveProjectResources(project, { - target, - identifier: "id", - }); + const resources = await this.backendFor(project).resolveProjectResources(project, { target }); const resource = resources.find( (candidate) => candidate.resourceType === input.resourceType && @@ -957,11 +954,41 @@ export class FsProjectManager implements ProjectManager { project: Project, input: ResolveProjectResourcesInput, ): Promise { - const target = await this.resolveExistingTarget(project, input.target); + const target = + (await this.resolveTarget(project, { target: input.target })) ?? + this.undeployedDefaultTarget(project, input); const resources = await this.backendFor(project).resolveProjectResources(project, { target }); return { resources, target }; } + // A project only gains its default target on first deploy, so reporting on one + // that has never deployed has no declared account or region to read. The region + // the CLI already resolved stands in, and the account stays blank because + // nothing is deployed under it: with no recorded stack the backend reports every + // resource local-only without reaching AWS, so it never reads the account. + private undeployedDefaultTarget( + project: Project, + input: ResolveProjectResourcesInput, + ): AwsDeploymentTarget { + const targetsPath = join(project.rootPath, "agentcore", "aws-targets.json"); + if (input.target !== DEFAULT_TARGET_NAME) { + throw new ProjectStateError( + `Project '${project.name}' has no deployment target named '${input.target}'. ` + + `Add it to ${targetsPath}, for example:\n\n${TARGETS_EXAMPLE}`, + ); + } + + const region = AgentCoreRegionSchema.safeParse(input.region); + if (!region.success) { + throw new ProjectStateError( + `Project '${project.name}' has not been deployed, and '${input.region}' is not a region ` + + `AgentCore supports. Re-run with --region, or add a target to ${targetsPath}.`, + ); + } + + return { name: DEFAULT_TARGET_NAME, account: "", region: region.data }; + } + private async resolveExistingTarget( project: Project, name: string, diff --git a/src/handlers/project/invoke/harness.tsx b/src/handlers/project/invoke/harness.tsx index 3bbee6506..559cb1351 100644 --- a/src/handlers/project/invoke/harness.tsx +++ b/src/handlers/project/invoke/harness.tsx @@ -6,7 +6,7 @@ import { JsonRendererKey, renderTuiAt } from "../../../tui"; import { JsonKey, RegionKey } from "../../keys"; import { invokeHarnessTurn } from "../../harness/invoke/operation"; import type { Core } from "../../types"; -import { coreOptsFromCtx } from "../../utils"; +import { coreOptsFromCtx, resourceIdFromArn } from "../../utils"; import { selectProjectResource } from "./selection"; export const createProjectInvokeHarnessHandler = ( @@ -40,13 +40,14 @@ export const createProjectInvokeHarnessHandler = ( resourceType: "harness", name, }); + const harnessId = resourceIdFromArn(deployed.id); const invokeCtx = ctx.withValue(RegionKey, deployed.target.region); if (!flags.prompt) { if (invokeCtx.require(JsonKey)) { throw new InputValidationError("required option '--prompt ' not specified"); } - let path = `/agentcore/harness/invoke/${encodeURIComponent(deployed.id)}`; + let path = `/agentcore/harness/invoke/${encodeURIComponent(harnessId)}`; if (flags["session-id"]) path += `/${encodeURIComponent(flags["session-id"])}`; if (flags.qualifier) path += `?qualifier=${encodeURIComponent(flags.qualifier)}`; await renderInvokeTui(path, invokeCtx, core, io); @@ -56,7 +57,7 @@ export const createProjectInvokeHarnessHandler = ( const result = await invokeHarnessTurn( core.harness, { - harnessId: deployed.id, + harnessId, prompt: flags.prompt, qualifier: flags.qualifier, sessionId: flags["session-id"], diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx index 2960221ea..ac3333c78 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -85,14 +85,14 @@ function backend() { resourceType: "runtime" as const, name, deploymentState: "deployed" as const, - id: RUNTIME_ID, + id: RUNTIME_ARN, target: input.target, })), ...project.spec.harnesses.map(({ name }) => ({ resourceType: "harness" as const, name, deploymentState: "deployed" as const, - id: HARNESS_ID, + id: HARNESS_ARN, target: input.target, })), ]; @@ -166,7 +166,7 @@ describe("project invoke", () => { expect(request.contentType).toBe("application/custom+json"); expect(core.runtime.calls.at(-1)!.args[1]).toEqual({ region: TARGET.region }); expect(io.stdout()).toBe("runtime response"); - expect(resolved.calls).toEqual([{ target: TARGET, identifier: "id" }]); + expect(resolved.calls).toEqual([{ target: TARGET }]); }); test("invokes a named Harness with its existing prompt contract in the target region", async () => { diff --git a/src/handlers/project/invoke/invoke.screen.test.tsx b/src/handlers/project/invoke/invoke.screen.test.tsx index e9eaf1970..81ead05b6 100644 --- a/src/handlers/project/invoke/invoke.screen.test.tsx +++ b/src/handlers/project/invoke/invoke.screen.test.tsx @@ -45,20 +45,23 @@ function endpoint(name: string): AgentRuntimeEndpoint { } const TARGET = { name: "default", account: "111122223333", region: "eu-west-1" } as const; +const ARN_PREFIX = `arn:aws:bedrock-agentcore:${TARGET.region}:${TARGET.account}`; +const RUNTIME_ARN = `${ARN_PREFIX}:runtime/runtime-123`; +const HARNESS_ARN = `${ARN_PREFIX}:harness/harness-123`; const DEPLOYED_RESOURCES: ResolvedProjectResource[] = [ { resourceType: "runtime", name: "checkout", deploymentState: "deployed" as const, - id: "runtime-123", + id: RUNTIME_ARN, target: TARGET, }, { resourceType: "harness", name: "support", deploymentState: "deployed" as const, - id: "harness-123", + id: HARNESS_ARN, target: TARGET, }, ]; @@ -69,7 +72,7 @@ function core(resources: ResolvedProjectResource[] = DEPLOYED_RESOURCES): TestCo resourceType: input.resourceType, name: input.name, deploymentState: "deployed" as const, - id: input.resourceType === "runtime" ? "runtime-123" : "harness-123", + id: input.resourceType === "runtime" ? RUNTIME_ARN : HARNESS_ARN, target: TARGET, }); value.projectManager.resolveProjectResources = async () => ({ resources, target: TARGET }); @@ -96,7 +99,7 @@ describe("project invoke picker", () => { resourceType: "harness", name: "support", deploymentState: "deployed" as const, - id: "harness-123", + id: HARNESS_ARN, target: TARGET, }, ]), @@ -114,7 +117,7 @@ describe("project invoke picker", () => { resourceType: "harness", name: "support", deploymentState: "deployed", - id: "harness-123", + id: HARNESS_ARN, target: TARGET, }, { diff --git a/src/handlers/project/invoke/runtime.tsx b/src/handlers/project/invoke/runtime.tsx index a80e6eecc..b352cb2cc 100644 --- a/src/handlers/project/invoke/runtime.tsx +++ b/src/handlers/project/invoke/runtime.tsx @@ -14,7 +14,7 @@ import { } from "../../runtime/invoke/request"; import { writeRuntimeInvokeResponse } from "../../runtime/invoke/response"; import type { Core } from "../../types"; -import { coreOptsFromCtx } from "../../utils"; +import { coreOptsFromCtx, resourceIdFromArn } from "../../utils"; import { selectProjectResource } from "./selection"; export const createProjectInvokeRuntimeHandler = ( @@ -62,6 +62,7 @@ export const createProjectInvokeRuntimeHandler = ( resourceType: "runtime", name, }); + const runtimeId = resourceIdFromArn(deployed.id); const invokeCtx = ctx.withValue(RegionKey, deployed.target.region); if (flags.payload === undefined) { @@ -83,7 +84,7 @@ export const createProjectInvokeRuntimeHandler = ( exitCode: ExitCode.USAGE, }); } - let path = `/agentcore/runtime/invoke/${encodeURIComponent(deployed.id)}`; + let path = `/agentcore/runtime/invoke/${encodeURIComponent(runtimeId)}`; if (flags.qualifier !== undefined) path += `/${encodeURIComponent(flags.qualifier)}`; const applicationHeaders = parseRuntimeInvokeHeaders(flags.header); const bearerToken = await resolveRuntimeInvokeTuiBearerToken( @@ -93,7 +94,7 @@ export const createProjectInvokeRuntimeHandler = ( await renderInvokeTui( path, invokeCtx.withValue(RuntimeInvokeLaunchContextKey, { - runtimeId: deployed.id, + runtimeId, runtimeSessionId: flags["session-id"], runtimeUserId: flags["user-id"], applicationHeaders, @@ -118,7 +119,7 @@ export const createProjectInvokeRuntimeHandler = ( const response = await invokeRuntimeTarget( core.runtime, { - runtimeId: deployed.id, + runtimeId, qualifier: flags.qualifier, payload: sources.payload, contentType: flags["content-type"], diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx index 28a4fc5bb..aaddeff31 100644 --- a/src/handlers/project/invoke/screen.tsx +++ b/src/handlers/project/invoke/screen.tsx @@ -8,8 +8,10 @@ import { Spinner } from "../../../components/ui/spinner"; import { ProjectKey, type Context } from "../../../router"; import { HarnessChat } from "../../harness/invoke/screen"; import { RegionKey } from "../../keys"; +import { DEFAULT_TARGET_NAME } from "../../../projectSchemas/aws-targets"; import { RuntimeInvokeConsole } from "../../runtime/invoke/screen"; import type { ScreenProps } from "../../types"; +import { resourceIdFromArn } from "../../utils"; import type { Project, ResolvedProjectResources } from "../types"; type ProjectInvokableRow = Record & { @@ -38,6 +40,7 @@ export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) { const [deployed, setDeployed] = useState(); const [destination, setDestination] = useState(); const [error, setError] = useState(); + const region = ctx.require(RegionKey); useEffect(() => { if (project) return; @@ -69,7 +72,7 @@ export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) { if (!project) return; let active = true; void core.projectManager - .resolveProjectResources(project, { target: "default" }) + .resolveProjectResources(project, { target: DEFAULT_TARGET_NAME, region }) .then((resolved) => { if (active) setDeployed(resolved); }) @@ -79,40 +82,40 @@ export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) { return () => { active = false; }; - }, [core.projectManager, project]); + }, [core.projectManager, project, region]); const rows = useMemo( () => - (deployed?.resources ?? []) - .filter( - ( - r, - ): r is typeof r & { - resourceType: "runtime" | "harness"; - deploymentState: "deployed"; - id: string; - } => - (r.resourceType === "runtime" || r.resourceType === "harness") && - r.deploymentState === "deployed", - ) - .map((resource) => { - if (resource.resourceType === "runtime") { - const configured = project?.spec.runtimes.find(({ name }) => name === resource.name); - return { - ...resource, - type: "Runtime" as const, + (deployed?.resources ?? []).flatMap((resource): ProjectInvokableRow[] => { + if (resource.deploymentState !== "deployed") return []; + if (resource.resourceType === "runtime") { + const configured = project?.spec.runtimes.find(({ name }) => name === resource.name); + return [ + { + resourceType: "runtime", + type: "Runtime", + name: resource.name, + id: resource.id, protocol: configured?.protocol ?? "HTTP", source: configured?.codeLocation ?? "-", - }; - } + }, + ]; + } + if (resource.resourceType === "harness") { const configured = project?.spec.harnesses.find(({ name }) => name === resource.name); - return { - ...resource, - type: "Harness" as const, - protocol: "-", - source: configured?.path ?? "-", - }; - }), + return [ + { + resourceType: "harness", + type: "Harness", + name: resource.name, + id: resource.id, + protocol: "-", + source: configured?.path ?? "-", + }, + ]; + } + return []; + }), [deployed, project], ); @@ -120,7 +123,7 @@ export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) { if (!deployed?.target) return; setDestination({ resourceType: row.resourceType, - id: row.id, + id: resourceIdFromArn(row.id), ctx: ctx.withValue(RegionKey, deployed.target.region), }); }; diff --git a/src/handlers/project/status/index.test.ts b/src/handlers/project/status/index.test.ts index 903e86121..f04910bc2 100644 --- a/src/handlers/project/status/index.test.ts +++ b/src/handlers/project/status/index.test.ts @@ -122,6 +122,34 @@ 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("nests a child under its owner's type when another collection reuses the name", async () => { + const subject = testStatusCommand([ + HARNESS_ROW, + deployed("gateway", "guards", `${ARN}:gateway/guards-1`), + deployed("policy-engine", "guards", `${ARN}:policy-engine/guards-1`), + deployed("policy", "noPii", `${ARN}:policy/noPii-1`, "guards"), + ]); + await inProject(subject, { + agentCoreGateways: [{ name: "guards", protocolType: "MCP", targets: [] }], + policyEngines: [{ name: "guards", policies: [policy("noPii")] }], + }); + + await subject.run(); + + const rows: Array<{ resourceType: string; children?: unknown[] }> = subject.json().resources; + const gateway = rows.find(({ resourceType }) => resourceType === "gateway"); + const engine = rows.find(({ resourceType }) => resourceType === "policy-engine"); + expect(gateway?.children).toBeUndefined(); + expect(engine?.children).toEqual([ + { + resourceType: "policy", + name: "noPii", + deploymentState: "deployed", + identifier: `${ARN}:policy/noPii-1`, + }, + ]); + }); + test("reports deployed resources by ARN, nesting children under their owner", async () => { const subject = testStatusCommand([ HARNESS_ROW, @@ -210,14 +238,19 @@ describe("project status handler", () => { }); }); - test("rejects a freshly created project that declares no targets", async () => { - const subject = testStatusCommand([]); + test("reports a freshly created project that declares no targets as undeployed", 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([]); + await subject.run(["--region", "us-west-2"]); + + expect(subject.json()).toEqual({ + projectName: "orders", + target: "default", + region: "us-west-2", + resources: [{ resourceType: "memory", name: "shortTerm", deploymentState: "local-only" }], + }); + expect(subject.targets).toEqual([{ name: "default", account: "", region: "us-west-2" }]); }); test("--target selects another target, and an unknown one is rejected", async () => { diff --git a/src/handlers/project/status/index.ts b/src/handlers/project/status/index.ts index d5f938863..4e04f03c1 100644 --- a/src/handlers/project/status/index.ts +++ b/src/handlers/project/status/index.ts @@ -2,6 +2,7 @@ import z from "zod"; import { DEFAULT_TARGET_NAME } from "../../../projectSchemas/aws-targets"; import { createHandler, flag, ProjectKey } from "../../../router"; import { JsonRendererKey } from "../../../tui"; +import { RegionKey } from "../../keys"; import type { DeployableResource, ProjectManager, ResolvedProjectResource } from "../types"; type StatusProjectHandlerConfig = { @@ -30,12 +31,29 @@ const toResourceStatus = (resource: ResolvedProjectResource): ResourceStatus => ...(resource.deploymentState === "deployed" ? { identifier: resource.id } : {}), }); +// Names are unique per spec collection but not across them, so a gateway and a +// policy engine can both be called 'guards'. Matching an owner by name alone +// files a policy under the gateway; the type pins it to the right one. +const OWNER_TYPE = { + "gateway-target": "gateway", + policy: "policy-engine", + "payment-connector": "payment-manager", +} satisfies Partial>; + +function ownerTypeOf(resourceType: DeployableResource): DeployableResource | undefined { + return OWNER_TYPE[resourceType as keyof typeof OWNER_TYPE]; +} + function nest(resources: ResolvedProjectResource[]): ResourceStatus[] { const entries = resources.map((resource) => ({ resource, row: toResourceStatus(resource) })); const top: ResourceStatus[] = []; for (const { resource, row } of entries) { + const ownerType = ownerTypeOf(resource.resourceType); const owner = resource.parent - ? entries.find(({ resource: other }) => !other.parent && other.name === resource.parent) + ? entries.find( + ({ resource: other }) => + other.resourceType === ownerType && other.name === resource.parent, + ) : undefined; if (owner) (owner.row.children ??= []).push(row); else top.push(row); @@ -58,6 +76,7 @@ export const createStatusProjectHandler = (config: StatusProjectHandlerConfig) = const project = ctx.require(ProjectKey); const resolved = await config.projectManager.resolveProjectResources(project, { target: flags.target, + region: ctx.require(RegionKey), }); const status: ProjectStatus = { diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 8569a8148..77e3db57b 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -168,6 +168,12 @@ export type ResolveProjectResourceInput = { export type ResolveProjectResourcesInput = { target: string; + /** + * The effective AWS region the CLI already resolved. Used to stand in for the + * default target on a project that has not deployed yet, the same way deploy + * synthesizes one — never to override a target aws-targets.json declares. + */ + region: string; }; export type DeployableResource = @@ -183,8 +189,8 @@ export type DeployableResource = | "policy-engine" | "policy" | "config-bundle" - | "payment" - | "capacity-provider"; + | "payment-manager" + | "payment-connector"; export type ResolvedProjectResource = { resourceType: DeployableResource; diff --git a/src/handlers/utils.test.tsx b/src/handlers/utils.test.tsx index 6b354f52b..faec485b8 100644 --- a/src/handlers/utils.test.tsx +++ b/src/handlers/utils.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { parseJsonArrayFlag, parseJsonObjectFlag, parseTags } from "./utils"; +import { parseJsonArrayFlag, parseJsonObjectFlag, parseTags, resourceIdFromArn } from "./utils"; describe("structured JSON flags", () => { test("parses object and array values", () => { @@ -65,3 +65,27 @@ describe("parseTags", () => { expect(() => parseTags(["noequals"])).toThrow("expected key=value"); }); }); + +describe("resourceIdFromArn", () => { + const ARN = "arn:aws:bedrock-agentcore:us-west-2:725476964917"; + + test("reads the id from runtime and harness ARNs", () => { + expect(resourceIdFromArn(`${ARN}:runtime/bugbashbot_bugbashbot-4OO4NyGq3L`)).toBe( + "bugbashbot_bugbashbot-4OO4NyGq3L", + ); + expect(resourceIdFromArn(`${ARN}:harness/abtestval_abtestval-xTbfyTZ3wd`)).toBe( + "abtestval_abtestval-xTbfyTZ3wd", + ); + }); + + test("reads the runtime id from a nested endpoint ARN rather than the endpoint name", () => { + expect(resourceIdFromArn(`${ARN}:runtime/checkout-AbC123/runtime-endpoint/DEFAULT`)).toBe( + "checkout-AbC123", + ); + }); + + test("rejects a value with no resource id", () => { + expect(() => resourceIdFromArn(`${ARN}:runtime`)).toThrow("Could not read a resource id"); + expect(() => resourceIdFromArn("")).toThrow("Could not read a resource id"); + }); +}); diff --git a/src/handlers/utils.tsx b/src/handlers/utils.tsx index a87ec614e..35fc98616 100644 --- a/src/handlers/utils.tsx +++ b/src/handlers/utils.tsx @@ -1,7 +1,12 @@ import type { Context } from "../router"; import type z from "zod"; import type { CoreOptions } from "../core/types"; -import { AgentCoreCLIError, InputValidationError, SilentCLIError } from "../errors"; +import { + AgentCoreCLIError, + InputValidationError, + MalformedServiceResponseError, + SilentCLIError, +} from "../errors"; import { formatZodError } from "../router/schema"; import { EndpointKey, RegionKey } from "./keys"; import { JsonRendererKey } from "../tui"; @@ -135,3 +140,16 @@ export function renderJsonError(ctx: Context, error: unknown): void { if (cliError instanceof SilentCLIError) return; ctx.require(JsonRendererKey).renderJson({ error: cliError.message }); } + +// resourceIdFromArn reads the resource id out of an AgentCore ARN. Resource +// resolution reports ARNs, but the data-plane APIs take bare ids (harnessId, +// agentRuntimeId) and reject an ARN, so the invoke paths convert at that +// boundary. The id is the segment after the resource type rather than the last +// one, because endpoint ARNs nest as runtime//runtime-endpoint/. +export function resourceIdFromArn(arn: string): string { + const id = arn.split("/")[1]; + if (!id) { + throw new MalformedServiceResponseError(`Could not read a resource id from '${arn}'.`); + } + return id; +}