From 0674a625a63c166c51a301d4302bc5eab135dd2b Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Tue, 8 Sep 2026 11:42:24 -0400 Subject: [PATCH 1/4] fix: fallback to legacy stack name in deployed state --- src/core/project/backends/cdk.test.ts | 69 ++++++++++++++++++- src/core/project/backends/cdk.ts | 15 ++-- .../project/backends/cdk/deployedState.ts | 4 ++ 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index da5b313d7..028a57b9f 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -872,7 +872,43 @@ describe("CdkBackend.resolveDeployedResources", () => { expect(subject.stackReads).toHaveLength(1); }); - test("fails without reading AWS when the target has no deployed stack ARN", async () => { + test("resolves resources from a legacy stack name when the target has no stack ARN", async () => { + const input = await project(); + input.spec = ProjectSpecSchema.parse({ + ...input.spec, + harnesses: [{ name: "support", path: "app/support" }], + }); + await updateTargetState(json, input.rootPath, TARGET.name, { + resources: { stackName: "AgentCore-example-default" }, + }); + const subject = harness({ + describedStack: { + StackName: "AgentCore-example-default", + CreationTime: new Date(0), + StackStatus: "CREATE_COMPLETE", + Outputs: [ + { + ExportName: "AgentCore-example-default-Harness-support-Id", + OutputValue: "support-AbCdEf1234", + }, + ], + }, + }); + + await expect( + subject.backend.resolveDeployedResources(input, { target: TARGET }), + ).resolves.toEqual([ + { + resourceType: "harness", + name: "support", + id: "support-AbCdEf1234", + target: TARGET, + }, + ]); + expect(subject.stackReads[0]?.stackName).toBe("AgentCore-example-default"); + }); + + test("fails without reading AWS when the target has no recorded stack", async () => { const input = await project(); const subject = harness({ describedStack: null }); @@ -1042,6 +1078,37 @@ describe("CdkBackend.resolveProjectResources", () => { ]); }); + test("reports legacy deployed harnesses using resources.stackName", async () => { + const input = await project(); + input.spec = { + ...input.spec, + harnesses: [{ name: "chat" }], + } as unknown as typeof input.spec; + await updateTargetState(json, input.rootPath, TARGET.name, { + resources: { stackName: S }, + }); + const subject = harness({ + describedStack: { + StackName: S, + CreationTime: new Date(0), + StackStatus: "CREATE_COMPLETE", + Outputs: [out(`${S}-Harness-chat-Arn`, "arn:harness/chat-1")], + }, + }); + + await expect( + subject.backend.resolveProjectResources(input, { target: TARGET }), + ).resolves.toEqual([ + { + resourceType: "harness", + name: "chat", + deploymentState: "deployed", + id: "arn:harness/chat-1", + }, + ]); + expect(subject.stackReads[0]?.stackName).toBe(S); + }); + test("reports local-only without reading AWS when the target has no recorded stack", async () => { const input = await project(); input.spec = { diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 3d0f4d51d..f9af9bae0 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -402,8 +402,9 @@ export class CdkBackend implements ProjectBackend { ): Promise { const { target } = input; const deployedState = await readDeployedState(this.json, project.rootPath); - const stackArn = deployedState.targets[target.name]?.stackArn; - if (!stackArn) { + const recorded = deployedState.targets[target.name]; + const stackReference = recorded?.stackArn ?? recorded?.resources?.stackName; + if (!stackReference) { throw new ProjectStateError( `Project '${project.name}' is not deployed to target '${target.name}'. ` + `Run 'agentcore project deploy --target ${target.name}' first.`, @@ -411,7 +412,7 @@ export class CdkBackend implements ProjectBackend { } const credentials = await this.credentialsForTarget(target); - const stack = await this.describeStack(target.region, credentials, stackArn); + const stack = await this.describeStack(target.region, credentials, stackReference); if (!stack) { throw new ProjectStateError( `Project '${project.name}' is not deployed to target '${target.name}'. ` + @@ -437,14 +438,16 @@ export class CdkBackend implements ProjectBackend { const { spec } = project; const deployedState = await readDeployedState(this.json, project.rootPath); const recorded = deployedState.targets[target.name]; + const stackReference = recorded?.stackArn ?? recorded?.resources?.stackName; // No recorded stack means nothing was ever deployed to this target, which - // every resource below reports as local-only. - const stack = recorded?.stackArn + // every resource below reports as local-only. Legacy projects record + // the stack name instead of target-level stack ARN. + const stack = stackReference ? await this.describeStack( target.region, await this.credentialsForTarget(target), - recorded.stackArn, + stackReference, ) : undefined; diff --git a/src/core/project/backends/cdk/deployedState.ts b/src/core/project/backends/cdk/deployedState.ts index c1f079be8..38352f82d 100644 --- a/src/core/project/backends/cdk/deployedState.ts +++ b/src/core/project/backends/cdk/deployedState.ts @@ -39,6 +39,10 @@ const CredentialStateSchema = z const ResourceStateSchema = z .object({ credentials: z.record(z.string(), CredentialStateSchema).optional(), + // The lwgact deployer recorded the CloudFormation stack name + // here. New deploys record the stack ARN instead. Keep this + // field so projects can be correctly inspected after upgrading. + stackName: z.string().optional(), }) .passthrough(); From 19c5eef35bbfeace172f790fc5a02a15000ba331 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Tue, 8 Sep 2026 11:44:10 -0400 Subject: [PATCH 2/4] chore: fix typo in comment --- src/core/project/backends/cdk/deployedState.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/project/backends/cdk/deployedState.ts b/src/core/project/backends/cdk/deployedState.ts index 38352f82d..791e0f836 100644 --- a/src/core/project/backends/cdk/deployedState.ts +++ b/src/core/project/backends/cdk/deployedState.ts @@ -39,7 +39,7 @@ const CredentialStateSchema = z const ResourceStateSchema = z .object({ credentials: z.record(z.string(), CredentialStateSchema).optional(), - // The lwgact deployer recorded the CloudFormation stack name + // The legacy deployer recorded the CloudFormation stack name // here. New deploys record the stack ARN instead. Keep this // field so projects can be correctly inspected after upgrading. stackName: z.string().optional(), From 5c2f6552ae3afbf3e53a055f29daf7350c3d961c Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Wed, 9 Sep 2026 09:01:37 -0400 Subject: [PATCH 3/4] chore: add handler level test for stack name resolution --- src/handlers/project/status/index.test.ts | 78 ++++++++++++++++++++--- 1 file changed, 70 insertions(+), 8 deletions(-) diff --git a/src/handlers/project/status/index.test.ts b/src/handlers/project/status/index.test.ts index d71d4702f..548cd3c51 100644 --- a/src/handlers/project/status/index.test.ts +++ b/src/handlers/project/status/index.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { createRootHandler } from "../../index"; @@ -7,11 +7,12 @@ import { createSilentLogger, TestCoreClient, TestGlobalConfigAccessor, + TestIdentityClient, testIO, ttyTestIO, waitFor, } from "../../../testing"; -import type { ProjectBackend } from "../../../core/project"; +import { CdkBackend, type ProjectBackend } from "../../../core/project"; import { ProjectStateError } from "../../../errors"; import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; import type { ResolvedProjectResource } from "../types"; @@ -48,16 +49,14 @@ function fakeBackend(deployed: ResolvedProjectResource[]) { return { targets, backend }; } -function testStatusCommand(deployed: ResolvedProjectResource[] = [], io = testIO()) { - const fake = fakeBackend(deployed); - const root = createRootHandler(new TestCoreClient({ backends: { CDK: fake.backend } }), { +function statusCommand(backend: ProjectBackend, io = testIO()) { + const root = createRootHandler(new TestCoreClient({ backends: { CDK: 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]), @@ -65,6 +64,11 @@ function testStatusCommand(deployed: ResolvedProjectResource[] = [], io = testIO }; } +function testStatusCommand(deployed: ResolvedProjectResource[] = [], io = testIO()) { + const fake = fakeBackend(deployed); + return { ...fake, ...statusCommand(fake.backend, io) }; +} + const originalCwd = process.cwd(); const tempDirectories: string[] = []; @@ -88,10 +92,10 @@ afterEach(() => { }); async function inProject( - subject: ReturnType, + subject: ReturnType, spec: Record = {}, targets: AwsDeploymentTarget[] = TARGETS, -): Promise { +): Promise { const directory = await mkdtemp(join(tmpdir(), "agentcore-status-")); tempDirectories.push(directory); process.chdir(directory); @@ -102,6 +106,7 @@ async function inProject( const current = JSON.parse(await Bun.file(specPath).text()); await writeFile(specPath, JSON.stringify({ ...current, ...spec })); process.chdir(projectRoot); + return projectRoot; } const deployed = ( @@ -133,6 +138,63 @@ 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 resources deployed by a legacy CLI using resources.stackName", async () => { + const stackName = "AgentCore-orders-default"; + const backend = new CdkBackend({ + logger: createSilentLogger(), + identity: new TestIdentityClient(), + resolveCredentials: async () => async () => ({ + accessKeyId: "access-key", + secretAccessKey: "secret-key", + }), + resolveAccount: async () => DEFAULT_TARGET.account, + describeStack: async (_region, _credentials, reference) => + reference === stackName + ? { + StackName: stackName, + CreationTime: new Date(0), + StackStatus: "CREATE_COMPLETE", + Outputs: [ + { + ExportName: `${stackName}-Harness-orders-Arn`, + OutputValue: `${ARN}:harness/orders-1`, + }, + ], + } + : undefined, + }); + const subject = statusCommand(backend); + const projectRoot = await inProject(subject); + const stateDirectory = join(projectRoot, "agentcore", ".cli"); + await mkdir(stateDirectory, { recursive: true }); + await Bun.write( + join(stateDirectory, "deployed-state.json"), + JSON.stringify({ + targets: { + default: { + resources: { stackName }, + }, + }, + }), + ); + + await subject.run(["--json"]); + + expect(subject.json()).toEqual({ + projectName: "orders", + target: "default", + region: DEFAULT_TARGET.region, + resources: [ + { + resourceType: "harness", + name: "orders", + deploymentState: "deployed", + id: `${ARN}:harness/orders-1`, + }, + ], + }); + }); + test("reports deployed resources by ARN, nesting children under their owner", async () => { const subject = testStatusCommand([ HARNESS_ROW, From b3ff2bc9743433fd91c41cdce84a8c5bf2279805 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Wed, 9 Sep 2026 09:02:31 -0400 Subject: [PATCH 4/4] chore: move stack reference logic into deployed state --- src/core/project/backends/cdk.test.ts | 31 ------------------- src/core/project/backends/cdk.ts | 14 ++++++--- .../backends/cdk/deployedState.test.ts | 25 +++++++++++++++ .../project/backends/cdk/deployedState.ts | 9 ++++++ 4 files changed, 43 insertions(+), 36 deletions(-) diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index 028a57b9f..cb48c8f0c 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -1078,37 +1078,6 @@ describe("CdkBackend.resolveProjectResources", () => { ]); }); - test("reports legacy deployed harnesses using resources.stackName", async () => { - const input = await project(); - input.spec = { - ...input.spec, - harnesses: [{ name: "chat" }], - } as unknown as typeof input.spec; - await updateTargetState(json, input.rootPath, TARGET.name, { - resources: { stackName: S }, - }); - const subject = harness({ - describedStack: { - StackName: S, - CreationTime: new Date(0), - StackStatus: "CREATE_COMPLETE", - Outputs: [out(`${S}-Harness-chat-Arn`, "arn:harness/chat-1")], - }, - }); - - await expect( - subject.backend.resolveProjectResources(input, { target: TARGET }), - ).resolves.toEqual([ - { - resourceType: "harness", - name: "chat", - deploymentState: "deployed", - id: "arn:harness/chat-1", - }, - ]); - expect(subject.stackReads[0]?.stackName).toBe(S); - }); - test("reports local-only without reading AWS when the target has no recorded stack", async () => { const input = await project(); input.spec = { diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index f9af9bae0..d7bb9bda0 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -42,7 +42,12 @@ import { stackArtifactForTarget, type StackArtifact, } from "./cdk/assembly"; -import { readDeployedState, removeTargetState, updateTargetState } from "./cdk/deployedState"; +import { + readDeployedState, + removeTargetState, + stackReferenceOf, + updateTargetState, +} from "./cdk/deployedState"; import { bootstrapStackReader, createCloudFormationStackReader, @@ -403,7 +408,7 @@ export class CdkBackend implements ProjectBackend { const { target } = input; const deployedState = await readDeployedState(this.json, project.rootPath); const recorded = deployedState.targets[target.name]; - const stackReference = recorded?.stackArn ?? recorded?.resources?.stackName; + const stackReference = stackReferenceOf(recorded); if (!stackReference) { throw new ProjectStateError( `Project '${project.name}' is not deployed to target '${target.name}'. ` + @@ -438,11 +443,10 @@ export class CdkBackend implements ProjectBackend { const { spec } = project; const deployedState = await readDeployedState(this.json, project.rootPath); const recorded = deployedState.targets[target.name]; - const stackReference = recorded?.stackArn ?? recorded?.resources?.stackName; + const stackReference = stackReferenceOf(recorded); // No recorded stack means nothing was ever deployed to this target, which - // every resource below reports as local-only. Legacy projects record - // the stack name instead of target-level stack ARN. + // every resource below reports as local-only. const stack = stackReference ? await this.describeStack( target.region, diff --git a/src/core/project/backends/cdk/deployedState.test.ts b/src/core/project/backends/cdk/deployedState.test.ts index 6f4630140..91bfc6ff7 100644 --- a/src/core/project/backends/cdk/deployedState.test.ts +++ b/src/core/project/backends/cdk/deployedState.test.ts @@ -9,6 +9,7 @@ import { DEPLOYED_STATE_RELATIVE_PATH, readDeployedState, removeTargetState, + stackReferenceOf, updateTargetState, } from "./deployedState"; @@ -58,6 +59,30 @@ describe("readDeployedState", () => { }); }); +describe("stackReferenceOf", () => { + test("prefers the exact stack ARN over the legacy stack name", () => { + expect( + stackReferenceOf({ + stackArn: "arn:stack:default", + resources: { stackName: "AgentCore-example-default" }, + }), + ).toBe("arn:stack:default"); + }); + + test("falls back to the legacy stack name", () => { + expect( + stackReferenceOf({ + resources: { stackName: "AgentCore-example-default" }, + }), + ).toBe("AgentCore-example-default"); + }); + + test("returns undefined when no stack was recorded", () => { + expect(stackReferenceOf(undefined)).toBeUndefined(); + expect(stackReferenceOf({})).toBeUndefined(); + }); +}); + describe("updateTargetState", () => { test("creates the file with the target entry", async () => { const root = await projectRoot(); diff --git a/src/core/project/backends/cdk/deployedState.ts b/src/core/project/backends/cdk/deployedState.ts index 791e0f836..48797804f 100644 --- a/src/core/project/backends/cdk/deployedState.ts +++ b/src/core/project/backends/cdk/deployedState.ts @@ -65,6 +65,15 @@ export const DeployedStateSchema = z export type DeployedState = z.infer; export type TargetState = z.infer; +/** + * Returns the CloudFormation reference recorded for a target. New deploys bind + * to the exact stack ARN; legacy deploys recorded only the stack name under + * resources, which CloudFormation also accepts when describing the stack. + */ +export function stackReferenceOf(state: TargetState | undefined): string | undefined { + return state?.stackArn ?? state?.resources?.stackName; +} + function statePathFor(projectRoot: string): string { return join(projectRoot, DEPLOYED_STATE_RELATIVE_PATH); }