From dd9270d115e02753e7609815f696b93676ab38b9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:50:01 -0400 Subject: [PATCH 1/4] chore: bump version to 0.12.0 (#1002) Co-authored-by: github-actions[bot] --- CHANGELOG.md | 31 +++++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- schemas/agentcore.schema.v1.json | 36 ++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec13d3ab0..8f434ffef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ All notable changes to this project will be documented in this file. +## [0.12.0] - 2026-04-28 + +### Added +- feat: add gateway import command with executionRoleArn support (#855) (2df1387) +- feat: runtime endpoint support in AgentCore CLI (#979) (41c59ef) +- feat: add project-name option to create (#969) (9b46fbb) + +### Fixed +- fix: duplicate header flash and help menu truncation (closes #895, closes #637) (#955) (e7b85c1) +- fix: show 'Computing diff changes...' step during deploy diff phase (#952) (a725d12) + +### Other Changes +- fix(e2e): add debug logging for gateway import CI failures (#1001) (8012d6c) +- fix(e2e): separate gateway import test and add PR-changed test detection (#999) (19b7d13) +- fix(import): remove resourceName/executionRoleArn co-variance refine (#996) (ad0ee58) +- test: speed up CI and fix mock cleanup gaps (#989) (51240ac) +- chore(deps-dev): bump esbuild from 0.27.4 to 0.28.0 (#862) (a778fb5) +- chore(deps-dev): bump hono from 4.12.12 to 4.12.14 (#868) (d64d2b8) +- chore(deps): bump the aws-sdk group across 1 directory with 14 updates (#912) (6061958) +- chore(deps-dev): bump @secretlint/secretlint-rule-preset-recommend (#914) (8ed1fe7) +- chore(deps-dev): bump @vitest/coverage-v8 from 4.1.2 to 4.1.5 (#915) (a74cab9) +- chore(deps-dev): bump secretlint from 11.4.1 to 12.2.0 (#916) (80fc145) +- chore(deps): bump postcss from 8.5.8 to 8.5.10 (#961) (760ac17) +- chore(deps-dev): bump aws-cdk-lib (#962) (8a264fb) +- ci: bump the github-actions group across 1 directory with 4 updates (#964) (9962c3e) +- test: configure git in browser tests workflow (#976) (17b5727) +- fix(import): remove experimental warning from import command (#977) (fdd6631) +- feat(invoke): add --prompt-file and stdin support for long prompts (#974) (f6a3e99) +- test: split browser tests into its own job, fix logs path (#975) (acbfb9e) +- fix(invoke): auto-generate session ID for bearer-token invocations (#953) (343fedc) + ## [0.11.0] - 2026-04-24 ### Added diff --git a/package-lock.json b/package-lock.json index 0bdaa8bd7..7497a04fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aws/agentcore", - "version": "0.11.0", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aws/agentcore", - "version": "0.11.0", + "version": "0.12.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index e63de389e..7b0687a99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aws/agentcore", - "version": "0.11.0", + "version": "0.12.0", "description": "CLI for Amazon Bedrock AgentCore", "license": "Apache-2.0", "repository": { diff --git a/schemas/agentcore.schema.v1.json b/schemas/agentcore.schema.v1.json index 15877fa42..c2dd737a7 100644 --- a/schemas/agentcore.schema.v1.json +++ b/schemas/agentcore.schema.v1.json @@ -301,6 +301,31 @@ "required": ["sessionStorage"], "additionalProperties": false } + }, + "endpoints": { + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 48, + "pattern": "^[a-zA-Z][a-zA-Z0-9_]{0,47}$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "version": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "description": { + "type": "string", + "maxLength": 200 + } + }, + "required": ["version"], + "additionalProperties": false + } } }, "required": ["name", "build", "entrypoint", "codeLocation"], @@ -716,6 +741,12 @@ "maxLength": 100, "pattern": "^[0-9a-zA-Z](?:[0-9a-zA-Z-]*[0-9a-zA-Z])?$" }, + "resourceName": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[0-9a-zA-Z](?:[0-9a-zA-Z-]*[0-9a-zA-Z])?$" + }, "description": { "type": "string" }, @@ -1201,6 +1232,11 @@ "required": ["policyEngineName", "mode"], "additionalProperties": false }, + "executionRoleArn": { + "type": "string", + "maxLength": 2048, + "pattern": "^arn:[^:]+:iam::\\d{12}:role\\/.+" + }, "tags": { "type": "object", "propertyNames": { From 13b34a32c75d05310bae2ec7681ddb1d1e8a8767 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath <42380254+tejaskash@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:09:34 -0400 Subject: [PATCH 2/4] test: remove 44 render-only and framework-testing tests (#998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: remove 44 render-only and framework-testing tests Delete TUI component test files that only verify prop passthrough or framework behavior (Ink rendering, setInterval lifecycle) without testing any application logic: - Cursor.test.tsx (5 tests): setInterval/clearInterval assertions - Header.test.tsx (4 tests): title/subtitle string presence - HelpText.test.tsx (2 tests): static string rendering - AwsTargetConfigUI.test.tsx (7 tests): help text string lookups - ConfirmReview.test.tsx (6 tests): field label rendering - LogLink.test.tsx (4 tests): prop passthrough - ScreenHeader.test.tsx (3 tests): prop passthrough - FatalError.test.tsx (5 tests): prop passthrough Trim Panel.test.tsx (6→3) and Screen.test.tsx (8→3), keeping only tests that verify real logic: border structure, responsive width adaptation, keyboard exit handling, and exitEnabled guard. Remove tautological expect(true).toBe(true) tests from assets.snapshot.test.ts; use describe.skipIf for empty asset dirs. Kept all tests in StepIndicator, ScreenLayout, TwoColumn, NextSteps, LogPanel, PathInput, and useFetchAccessFlow — audit flagged some as framework tests but they verify real conditional/interaction logic. * fix: restore AwsTargetConfigUI tests — pure function, not render test getAwsConfigHelpText is a switch over AwsConfigPhase that maps states to help strings. The undefined return for loading/terminal phases is a contract consumed by DeployScreen.tsx via ?? HELP_TEXT.EXIT. These tests guard that fallback, not framework rendering behavior. --- src/assets/__tests__/assets.snapshot.test.ts | 34 +++---- .../__tests__/ConfirmReview.test.tsx | 89 ------------------- .../tui/components/__tests__/Cursor.test.tsx | 40 --------- .../components/__tests__/FatalError.test.tsx | 45 ---------- .../tui/components/__tests__/Header.test.tsx | 34 ------- .../components/__tests__/HelpText.test.tsx | 20 ----- .../tui/components/__tests__/LogLink.test.tsx | 30 ------- .../tui/components/__tests__/Panel.test.tsx | 56 +----------- .../tui/components/__tests__/Screen.test.tsx | 60 ------------- .../__tests__/ScreenHeader.test.tsx | 30 ------- 10 files changed, 12 insertions(+), 426 deletions(-) delete mode 100644 src/cli/tui/components/__tests__/ConfirmReview.test.tsx delete mode 100644 src/cli/tui/components/__tests__/Cursor.test.tsx delete mode 100644 src/cli/tui/components/__tests__/FatalError.test.tsx delete mode 100644 src/cli/tui/components/__tests__/Header.test.tsx delete mode 100644 src/cli/tui/components/__tests__/HelpText.test.tsx delete mode 100644 src/cli/tui/components/__tests__/LogLink.test.tsx delete mode 100644 src/cli/tui/components/__tests__/ScreenHeader.test.tsx diff --git a/src/assets/__tests__/assets.snapshot.test.ts b/src/assets/__tests__/assets.snapshot.test.ts index 64fb38ab9..e0d3735cf 100644 --- a/src/assets/__tests__/assets.snapshot.test.ts +++ b/src/assets/__tests__/assets.snapshot.test.ts @@ -87,36 +87,22 @@ describe('Assets Directory Snapshots', () => { }); }); - describe('Static assets', () => { + describe.skipIf(assetFiles.filter(f => f.startsWith('static/')).length === 0)('Static assets', () => { const staticFiles = assetFiles.filter(f => f.startsWith('static/')); - if (staticFiles.length > 0) { - it.each(staticFiles)('static/%s should match snapshot', file => { - const content = readFileContent(path.join(ASSETS_DIR, file)); - expect(content).toMatchSnapshot(); - }); - } else { - it('static directory is empty or does not exist', () => { - // Static assets may not exist - expect(true).toBe(true); - }); - } + it.each(staticFiles)('static/%s should match snapshot', file => { + const content = readFileContent(path.join(ASSETS_DIR, file)); + expect(content).toMatchSnapshot(); + }); }); - describe('TypeScript assets', () => { + describe.skipIf(assetFiles.filter(f => f.startsWith('typescript/')).length === 0)('TypeScript assets', () => { const tsFiles = assetFiles.filter(f => f.startsWith('typescript/')); - if (tsFiles.length > 0) { - it.each(tsFiles)('typescript/%s should match snapshot', file => { - const content = readFileContent(path.join(ASSETS_DIR, file)); - expect(content).toMatchSnapshot(); - }); - } else { - it('typescript directory is empty or contains only placeholder files', () => { - // TypeScript assets may not exist yet - expect(true).toBe(true); - }); - } + it.each(tsFiles)('typescript/%s should match snapshot', file => { + const content = readFileContent(path.join(ASSETS_DIR, file)); + expect(content).toMatchSnapshot(); + }); }); describe('Root-level assets', () => { diff --git a/src/cli/tui/components/__tests__/ConfirmReview.test.tsx b/src/cli/tui/components/__tests__/ConfirmReview.test.tsx deleted file mode 100644 index f9c142000..000000000 --- a/src/cli/tui/components/__tests__/ConfirmReview.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { ConfirmReview } from '../ConfirmReview.js'; -import { render } from 'ink-testing-library'; -import React from 'react'; -import { describe, expect, it } from 'vitest'; - -describe('ConfirmReview', () => { - it('renders default title and help text', () => { - const { lastFrame } = render(); - const frame = lastFrame()!; - - expect(frame).toContain('Review Configuration'); - expect(frame).toContain('Enter confirm'); - expect(frame).toContain('Esc back'); - }); - - it('renders custom title', () => { - const { lastFrame } = render( - - ); - - expect(lastFrame()).toContain('Review Deploy'); - expect(lastFrame()).not.toContain('Review Configuration'); - }); - - it('renders each field as label: value on the same line', () => { - const { lastFrame } = render( - - ); - const lines = lastFrame()!.split('\n'); - - // Each label and its value should appear on the same line - const nameLine = lines.find(l => l.includes('Name'))!; - expect(nameLine).toContain('my-agent'); - - const sdkLine = lines.find(l => l.includes('SDK'))!; - expect(sdkLine).toContain('Strands'); - - const langLine = lines.find(l => l.includes('Language'))!; - expect(langLine).toContain('Python'); - }); - - it('renders label with colon separator', () => { - const { lastFrame } = render(); - const lines = lastFrame()!.split('\n'); - - const regionLine = lines.find(l => l.includes('Region'))!; - expect(regionLine).toMatch(/Region.*:.*us-east-1/); - }); - - it('renders custom help text replacing default', () => { - const { lastFrame } = render( - - ); - - expect(lastFrame()).toContain('Press Y to confirm'); - expect(lastFrame()).not.toContain('Enter confirm'); - }); - - it('renders multiple fields in order', () => { - const { lastFrame } = render( - - ); - const frame = lastFrame()!; - - // All three labels should be present - expect(frame).toContain('First'); - expect(frame).toContain('Second'); - expect(frame).toContain('Third'); - - // Verify ordering: First appears before Second - const firstIdx = frame.indexOf('First'); - const secondIdx = frame.indexOf('Second'); - const thirdIdx = frame.indexOf('Third'); - expect(firstIdx).toBeLessThan(secondIdx); - expect(secondIdx).toBeLessThan(thirdIdx); - }); -}); diff --git a/src/cli/tui/components/__tests__/Cursor.test.tsx b/src/cli/tui/components/__tests__/Cursor.test.tsx deleted file mode 100644 index 376b48cb8..000000000 --- a/src/cli/tui/components/__tests__/Cursor.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Cursor } from '../Cursor.js'; -import { render } from 'ink-testing-library'; -import React from 'react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -afterEach(() => vi.restoreAllMocks()); - -describe('Cursor', () => { - it('renders the provided character on initial mount', () => { - const { lastFrame } = render(); - expect(lastFrame()).toContain('X'); - }); - - it('sets up a blink interval using setInterval', () => { - const spy = vi.spyOn(globalThis, 'setInterval'); - render(); - // Cursor uses setInterval with the provided interval for blinking - expect(spy).toHaveBeenCalledWith(expect.any(Function), 500); - }); - - it('uses custom interval value for the blink timer', () => { - const spy = vi.spyOn(globalThis, 'setInterval'); - render(); - expect(spy).toHaveBeenCalledWith(expect.any(Function), 200); - }); - - it('renders with default space character when no char prop given', () => { - const { lastFrame } = render(); - // Default char is a space — component should render without errors - expect(lastFrame()).toBeDefined(); - }); - - it('cleans up interval timer on unmount', () => { - const spy = vi.spyOn(globalThis, 'clearInterval'); - const { unmount } = render(); - unmount(); - // clearInterval should be called during cleanup - expect(spy).toHaveBeenCalled(); - }); -}); diff --git a/src/cli/tui/components/__tests__/FatalError.test.tsx b/src/cli/tui/components/__tests__/FatalError.test.tsx deleted file mode 100644 index e13bd4807..000000000 --- a/src/cli/tui/components/__tests__/FatalError.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { FatalError } from '../FatalError.js'; -import { render } from 'ink-testing-library'; -import React from 'react'; -import { describe, expect, it } from 'vitest'; - -describe('FatalError', () => { - it('renders error message', () => { - const { lastFrame } = render(); - - expect(lastFrame()).toContain('Something went wrong'); - }); - - it('renders detail when provided', () => { - const { lastFrame } = render(); - - expect(lastFrame()).toContain('Error'); - expect(lastFrame()).toContain('Check your config file'); - }); - - it('renders suggested command when provided', () => { - const { lastFrame } = render(); - - expect(lastFrame()).toContain('No project found'); - expect(lastFrame()).toContain('agentcore create'); - expect(lastFrame()).toContain('to fix this'); - }); - - it('renders all props together', () => { - const { lastFrame } = render( - - ); - - expect(lastFrame()).toContain('Deploy failed'); - expect(lastFrame()).toContain('Stack is in ROLLBACK state'); - expect(lastFrame()).toContain('agentcore status'); - }); - - it('does not render detail when not provided', () => { - const { lastFrame } = render(); - const frame = lastFrame()!; - - expect(frame).toContain('Error'); - expect(frame).not.toContain('to fix this'); - }); -}); diff --git a/src/cli/tui/components/__tests__/Header.test.tsx b/src/cli/tui/components/__tests__/Header.test.tsx deleted file mode 100644 index 5abf43584..000000000 --- a/src/cli/tui/components/__tests__/Header.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Header } from '../Header.js'; -import { render } from 'ink-testing-library'; -import React from 'react'; -import { describe, expect, it } from 'vitest'; - -describe('Header', () => { - it('renders title', () => { - const { lastFrame } = render(
); - - expect(lastFrame()).toContain('AgentCore'); - }); - - it('renders subtitle when provided', () => { - const { lastFrame } = render(
); - - expect(lastFrame()).toContain('AgentCore'); - expect(lastFrame()).toContain('CLI for AI agents'); - }); - - it('renders version when provided', () => { - const { lastFrame } = render(
); - - expect(lastFrame()).toContain('AgentCore'); - expect(lastFrame()).toContain('1.2.3'); - }); - - it('renders all props', () => { - const { lastFrame } = render(
); - - expect(lastFrame()).toContain('AgentCore'); - expect(lastFrame()).toContain('CLI'); - expect(lastFrame()).toContain('0.1.0'); - }); -}); diff --git a/src/cli/tui/components/__tests__/HelpText.test.tsx b/src/cli/tui/components/__tests__/HelpText.test.tsx deleted file mode 100644 index ccfcc146c..000000000 --- a/src/cli/tui/components/__tests__/HelpText.test.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { ExitHelpText, HelpText } from '../HelpText.js'; -import { render } from 'ink-testing-library'; -import React from 'react'; -import { describe, expect, it } from 'vitest'; - -describe('HelpText', () => { - it('renders text', () => { - const { lastFrame } = render(); - - expect(lastFrame()).toContain('Press Enter to continue'); - }); -}); - -describe('ExitHelpText', () => { - it('renders exit instructions', () => { - const { lastFrame } = render(); - - expect(lastFrame()).toContain('Press ESC or Ctrl+Q to exit'); - }); -}); diff --git a/src/cli/tui/components/__tests__/LogLink.test.tsx b/src/cli/tui/components/__tests__/LogLink.test.tsx deleted file mode 100644 index 4c79d7904..000000000 --- a/src/cli/tui/components/__tests__/LogLink.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { LogLink } from '../LogLink.js'; -import { render } from 'ink-testing-library'; -import React from 'react'; -import { describe, expect, it } from 'vitest'; - -describe('LogLink', () => { - it('renders with prefix and relative path', () => { - const { lastFrame } = render(); - - expect(lastFrame()).toContain('Log:'); - }); - - it('renders custom display text', () => { - const { lastFrame } = render(); - - expect(lastFrame()).toContain('test.log'); - }); - - it('hides prefix when showPrefix is false', () => { - const { lastFrame } = render(); - - expect(lastFrame()).not.toContain('Log:'); - }); - - it('renders custom label', () => { - const { lastFrame } = render(); - - expect(lastFrame()).toContain('Output:'); - }); -}); diff --git a/src/cli/tui/components/__tests__/Panel.test.tsx b/src/cli/tui/components/__tests__/Panel.test.tsx index 9435ff66f..5d3a5eec4 100644 --- a/src/cli/tui/components/__tests__/Panel.test.tsx +++ b/src/cli/tui/components/__tests__/Panel.test.tsx @@ -25,13 +25,12 @@ describe('Panel', () => { ); const frame = lastFrame()!; expect(frame).toContain('Panel body'); - // Verify border structure: top-left corner on first line, bottom-right on last const lines = frame.split('\n'); expect(lines[0]).toContain('╭'); expect(lines[lines.length - 1]).toContain('╯'); }); - it('renders title as first line inside border when provided', () => { + it('renders title before body content', () => { const { lastFrame } = render( body @@ -39,43 +38,7 @@ describe('Panel', () => { ); const frame = lastFrame()!; expect(frame).toContain('Settings'); - expect(frame).toContain('body'); - // Title should appear before body in the output - const titleIdx = frame.indexOf('Settings'); - const bodyIdx = frame.indexOf('body'); - expect(titleIdx).toBeLessThan(bodyIdx); - }); - - it('does not include title text when title is omitted', () => { - const { lastFrame } = render( - - body only - - ); - const frame = lastFrame()!; - expect(frame).toContain('body only'); - // The frame should only have border + body, no extra text before body - const lines = frame.split('\n').filter(l => l.trim().length > 0); - // First meaningful content line after the top border should be the body - expect(lines.length).toBeGreaterThanOrEqual(3); // top border, body, bottom border - }); - - it('renders with fullWidth when fullWidth prop is true', () => { - // With fullWidth=false (default), Panel uses contentWidth from context - // With fullWidth=true, Panel uses 100% - const { lastFrame: narrowFrame } = render( - - narrow - - ); - const { lastFrame: wideFrame } = render( - - wide - - ); - // Both should render their content - expect(narrowFrame()).toContain('narrow'); - expect(wideFrame()).toContain('wide'); + expect(frame.indexOf('Settings')).toBeLessThan(frame.indexOf('body')); }); it('adapts to different content widths from context', () => { @@ -93,23 +56,8 @@ describe('Panel', () => { ); - // Both render successfully — the narrow panel's top border should be shorter const narrowTopLine = narrow()!.split('\n')[0]!; const wideTopLine = wide()!.split('\n')[0]!; expect(narrowTopLine.length).toBeLessThan(wideTopLine.length); }); - - it('renders with borderColor prop without breaking layout', () => { - const { lastFrame } = render( - - colored border - - ); - const frame = lastFrame()!; - expect(frame).toContain('colored border'); - // Border structure should still be intact - const lines = frame.split('\n'); - expect(lines[0]).toContain('╭'); - expect(lines[lines.length - 1]).toContain('╯'); - }); }); diff --git a/src/cli/tui/components/__tests__/Screen.test.tsx b/src/cli/tui/components/__tests__/Screen.test.tsx index 707b2242f..597163aba 100644 --- a/src/cli/tui/components/__tests__/Screen.test.tsx +++ b/src/cli/tui/components/__tests__/Screen.test.tsx @@ -9,46 +9,6 @@ const ESCAPE = '\x1B'; afterEach(() => vi.restoreAllMocks()); describe('Screen', () => { - it('renders title in the header', () => { - const { lastFrame } = render( - - Content - - ); - - expect(lastFrame()).toContain('Deploy'); - }); - - it('renders children content', () => { - const { lastFrame } = render( - - Hello World - - ); - - expect(lastFrame()).toContain('Hello World'); - }); - - it('renders default help text when none provided', () => { - const { lastFrame } = render( - - Content - - ); - - expect(lastFrame()).toContain('Esc back'); - }); - - it('renders custom help text when provided', () => { - const { lastFrame } = render( - - Content - - ); - - expect(lastFrame()).toContain('Press Enter to continue'); - }); - it('calls onExit on Escape key', async () => { const onExit = vi.fn(); const { stdin } = render( @@ -89,24 +49,4 @@ describe('Screen', () => { expect(onExit).not.toHaveBeenCalled(); }); - - it('renders header content when provided', () => { - const { lastFrame } = render( - Status: Active}> - Content - - ); - - expect(lastFrame()).toContain('Status: Active'); - }); - - it('renders footer content when provided', () => { - const { lastFrame } = render( - 3 items selected}> - Content - - ); - - expect(lastFrame()).toContain('3 items selected'); - }); }); diff --git a/src/cli/tui/components/__tests__/ScreenHeader.test.tsx b/src/cli/tui/components/__tests__/ScreenHeader.test.tsx deleted file mode 100644 index 941116a2e..000000000 --- a/src/cli/tui/components/__tests__/ScreenHeader.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { ScreenHeader } from '../ScreenHeader.js'; -import { Text } from 'ink'; -import { render } from 'ink-testing-library'; -import React from 'react'; -import { describe, expect, it } from 'vitest'; - -describe('ScreenHeader', () => { - it('renders title', () => { - const { lastFrame } = render(); - - expect(lastFrame()).toContain('Deploy'); - }); - - it('renders children when provided', () => { - const { lastFrame } = render( - - Target: us-east-1 - - ); - - expect(lastFrame()).toContain('Status'); - expect(lastFrame()).toContain('Target: us-east-1'); - }); - - it('does not render children area when no children', () => { - const { lastFrame } = render(); - - expect(lastFrame()).toContain('Help'); - }); -}); From 29b6522c9b16c7366b1eaf68bd89a4293eca95aa Mon Sep 17 00:00:00 2001 From: Jesse Turner <57651174+jesseturner21@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:17:45 -0400 Subject: [PATCH 3/4] fix(import): use GatewayNameSchema for gateway import name validation (#1011) The import gateway command used NAME_REGEX which only allowed underscores and max 48 chars, rejecting valid gateway names with hyphens like "agentcore-gateway". Switch to GatewayNameSchema which matches the actual AWS API: alphanumeric with hyphens, up to 100 chars. Constraint: AWS CreateGateway API allows [0-9a-zA-Z] with hyphens Rejected: Updating NAME_REGEX | it is shared with other import commands that have different naming rules Confidence: high Scope-risk: narrow --- .../commands/import/__tests__/import-gateway-flow.test.ts | 5 ++--- src/cli/commands/import/import-gateway.ts | 8 +++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/cli/commands/import/__tests__/import-gateway-flow.test.ts b/src/cli/commands/import/__tests__/import-gateway-flow.test.ts index 5b322e942..183a6e63f 100644 --- a/src/cli/commands/import/__tests__/import-gateway-flow.test.ts +++ b/src/cli/commands/import/__tests__/import-gateway-flow.test.ts @@ -310,14 +310,13 @@ describe('handleImportGateway', () => { // ── Name validation ───────────────────────────────────────────────────── describe('Name validation', () => { - it('rejects invalid name starting with a number', async () => { - mockGetGatewayDetail.mockResolvedValue(makeGatewayDetail({ name: '123gateway' })); + it('rejects invalid name with special characters', async () => { + mockGetGatewayDetail.mockResolvedValue(makeGatewayDetail({ name: 'gateway_with_underscores!' })); const result = await handleImportGateway({ arn: GATEWAY_ARN }); expect(result.success).toBe(false); expect(result.error).toContain('Invalid name'); - expect(result.error).toContain('must start with a letter'); expect(mockConfigIOInstance.writeProjectSpec).not.toHaveBeenCalled(); }); diff --git a/src/cli/commands/import/import-gateway.ts b/src/cli/commands/import/import-gateway.ts index 491246438..3c2384e03 100644 --- a/src/cli/commands/import/import-gateway.ts +++ b/src/cli/commands/import/import-gateway.ts @@ -9,6 +9,7 @@ import type { GatewayPolicyEngineConfiguration, OutboundAuth, } from '../../../schema'; +import { GatewayNameSchema } from '../../../schema'; import type { GatewayDetail, GatewayTargetDetail } from '../../aws/agentcore-control'; import { getGatewayDetail, @@ -17,7 +18,7 @@ import { listAllGateways, } from '../../aws/agentcore-control'; import { isAccessDeniedError } from '../../errors'; -import { ANSI, NAME_REGEX } from './constants'; +import { ANSI } from './constants'; import { executeCdkImportPipeline } from './import-pipeline'; import { failResult, @@ -425,10 +426,11 @@ export async function handleImportGateway(options: ImportResourceOptions): Promi // 4. Validate name logger.startStep('Validate name'); let localName = options.name ?? gatewayDetail.name; - if (!NAME_REGEX.test(localName)) { + const nameResult = GatewayNameSchema.safeParse(localName); + if (!nameResult.success) { return failResult( logger, - `Invalid name "${localName}". Name must start with a letter and contain only letters, numbers, and underscores (max 48 chars).`, + `Invalid name "${localName}". ${nameResult.error.issues[0]?.message ?? 'Invalid gateway name'}`, 'gateway', localName ); From 76b07aaee08b1ac7c03e87cd22f02bb8aa41ce1b Mon Sep 17 00:00:00 2001 From: Avi Alpert <131792194+avi-alpert@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:27:33 -0400 Subject: [PATCH 4/4] feat: add CloudWatch traces API for web UI (#997) --- src/cli/commands/dev/browser-mode.ts | 43 +++ .../__tests__/cloudwatch-traces.test.ts | 233 ++++++++++++++++ src/cli/operations/dev/web-ui/api-types.ts | 34 +++ .../dev/web-ui/handlers/cloudwatch-traces.ts | 166 +++++++++++ .../operations/dev/web-ui/handlers/index.ts | 1 + src/cli/operations/dev/web-ui/index.ts | 7 + src/cli/operations/dev/web-ui/web-server.ts | 33 +++ .../traces/__tests__/get-trace.test.ts | 233 ++++++++++++++++ .../traces/__tests__/list-traces.test.ts | 135 +++++++++ src/cli/operations/traces/get-trace.ts | 263 +++++++++++------- src/cli/operations/traces/index.ts | 16 +- src/cli/operations/traces/insights-query.ts | 85 ++++++ src/cli/operations/traces/list-traces.ts | 115 ++------ src/cli/operations/traces/types.ts | 77 +++++ 14 files changed, 1244 insertions(+), 197 deletions(-) create mode 100644 src/cli/operations/dev/web-ui/__tests__/cloudwatch-traces.test.ts create mode 100644 src/cli/operations/dev/web-ui/handlers/cloudwatch-traces.ts create mode 100644 src/cli/operations/traces/__tests__/get-trace.test.ts create mode 100644 src/cli/operations/traces/__tests__/list-traces.test.ts create mode 100644 src/cli/operations/traces/insights-query.ts create mode 100644 src/cli/operations/traces/types.ts diff --git a/src/cli/commands/dev/browser-mode.ts b/src/cli/commands/dev/browser-mode.ts index 3846dea85..c35113e6d 100644 --- a/src/cli/commands/dev/browser-mode.ts +++ b/src/cli/commands/dev/browser-mode.ts @@ -9,6 +9,8 @@ import { runWebUI, } from '../../operations/dev/web-ui'; import { listMemoryRecords, retrieveMemoryRecords } from '../../operations/memory'; +import { loadDeployedProjectConfig, resolveAgent } from '../../operations/resolve-agent'; +import { fetchTraceRecords, listTraces } from '../../operations/traces'; import path from 'node:path'; interface DeployedHandlers { @@ -192,6 +194,47 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise { ? (agentNameParam, startTime, endTime) => collector.listTraces(agentNameParam, startTime, endTime) : undefined, onGetTrace: collector ? (agentNameParam, traceId) => collector.getTraceSpans(agentNameParam, traceId) : undefined, + onListCloudWatchTraces: async (agentName, _harnessName, startTime, endTime) => { + try { + const configIO = new ConfigIO({ baseDir }); + const context = await loadDeployedProjectConfig(configIO); + const resolved = resolveAgent(context, { runtime: agentName }); + if (!resolved.success) return { success: false, error: resolved.error }; + return listTraces({ + region: resolved.agent.region, + runtimeId: resolved.agent.runtimeId, + agentName: resolved.agent.agentName, + startTime, + endTime, + }); + } catch (err) { + return { + success: false, + error: `Failed to list CloudWatch traces: ${err instanceof Error ? err.message : String(err)}`, + }; + } + }, + onGetCloudWatchTrace: async (agentName, _harnessName, traceId, startTime, endTime) => { + try { + const configIO = new ConfigIO({ baseDir }); + const context = await loadDeployedProjectConfig(configIO); + const resolved = resolveAgent(context, { runtime: agentName }); + if (!resolved.success) return { success: false, error: resolved.error }; + return fetchTraceRecords({ + region: resolved.agent.region, + runtimeId: resolved.agent.runtimeId, + traceId, + startTime, + endTime, + includeSpans: true, + }); + } catch (err) { + return { + success: false, + error: `Failed to get CloudWatch trace: ${err instanceof Error ? err.message : String(err)}`, + }; + } + }, onListMemoryRecords: async (memoryName, namespace, strategyId) => { const deployed = await resolveDeployedHandlers(baseDir, onLog); if (!deployed.onListMemoryRecords) return { success: false, error: 'No deployed AgentCore Memory found' }; diff --git a/src/cli/operations/dev/web-ui/__tests__/cloudwatch-traces.test.ts b/src/cli/operations/dev/web-ui/__tests__/cloudwatch-traces.test.ts new file mode 100644 index 000000000..f0210f63a --- /dev/null +++ b/src/cli/operations/dev/web-ui/__tests__/cloudwatch-traces.test.ts @@ -0,0 +1,233 @@ +import { handleGetCloudWatchTrace, handleListCloudWatchTraces } from '../handlers/cloudwatch-traces.js'; +import type { RouteContext } from '../handlers/route-context.js'; +import type { IncomingMessage, ServerResponse } from 'http'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +function mockRes(): ServerResponse & { _status: number; _headers: Record; _body: string } { + const res = { + _status: 0, + _headers: {} as Record, + _body: '', + writeHead(status: number, headers?: Record) { + res._status = status; + if (headers) Object.assign(res._headers, headers); + return res; + }, + setHeader(name: string, value: string) { + res._headers[name] = value; + }, + end(body?: string) { + if (body) res._body = body; + }, + }; + return res as unknown as ServerResponse & { _status: number; _headers: Record; _body: string }; +} + +function mockReq(url: string): IncomingMessage { + return { url, headers: { host: 'localhost:8081' } } as unknown as IncomingMessage; +} + +function mockCtx(overrides: Partial = {}): RouteContext { + return { + options: { + mode: 'dev', + agents: [], + harnesses: [], + uiPort: 8081, + ...overrides, + }, + runningAgents: new Map(), + startingAgents: new Map(), + agentErrors: new Map(), + setCorsHeaders: vi.fn(), + readBody: vi.fn(), + } as RouteContext; +} + +describe('handleListCloudWatchTraces', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns 404 when no handler configured', async () => { + const ctx = mockCtx(); + const req = mockReq('/api/cloudwatch-traces?agentName=my-agent'); + const res = mockRes(); + + await handleListCloudWatchTraces(ctx, req, res); + + expect(res._status).toBe(404); + const body = JSON.parse(res._body); + expect(body.success).toBe(false); + expect(body.error).toContain('not available'); + }); + + it('returns 400 when neither agentName nor harnessName provided', async () => { + const handler = vi.fn(); + const ctx = mockCtx({ onListCloudWatchTraces: handler }); + const req = mockReq('/api/cloudwatch-traces'); + const res = mockRes(); + + await handleListCloudWatchTraces(ctx, req, res); + + expect(res._status).toBe(400); + const body = JSON.parse(res._body); + expect(body.success).toBe(false); + expect(body.error).toContain('agentName'); + expect(body.error).toContain('harnessName'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('returns 400 when both agentName and harnessName provided', async () => { + const handler = vi.fn(); + const ctx = mockCtx({ onListCloudWatchTraces: handler }); + const req = mockReq('/api/cloudwatch-traces?agentName=a&harnessName=h'); + const res = mockRes(); + + await handleListCloudWatchTraces(ctx, req, res); + + expect(res._status).toBe(400); + const body = JSON.parse(res._body); + expect(body.success).toBe(false); + expect(body.error).toContain('agentName'); + expect(body.error).toContain('harnessName'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('calls handler with agentName and returns traces', async () => { + const traces = [{ traceId: 't1' }, { traceId: 't2' }]; + const handler = vi.fn().mockResolvedValue({ success: true, traces }); + const ctx = mockCtx({ onListCloudWatchTraces: handler }); + const req = mockReq('/api/cloudwatch-traces?agentName=my-agent'); + const res = mockRes(); + + await handleListCloudWatchTraces(ctx, req, res); + + expect(res._status).toBe(200); + expect(handler).toHaveBeenCalledWith('my-agent', undefined, undefined, undefined); + const body = JSON.parse(res._body); + expect(body.success).toBe(true); + expect(body.traces).toEqual(traces); + }); + + it('calls handler with harnessName', async () => { + const handler = vi.fn().mockResolvedValue({ success: true, traces: [] }); + const ctx = mockCtx({ onListCloudWatchTraces: handler }); + const req = mockReq('/api/cloudwatch-traces?harnessName=my-harness'); + const res = mockRes(); + + await handleListCloudWatchTraces(ctx, req, res); + + expect(res._status).toBe(200); + expect(handler).toHaveBeenCalledWith(undefined, 'my-harness', undefined, undefined); + }); + + it('returns 500 when handler throws', async () => { + const handler = vi.fn().mockRejectedValue(new Error('boom')); + const ctx = mockCtx({ onListCloudWatchTraces: handler }); + const req = mockReq('/api/cloudwatch-traces?agentName=my-agent'); + const res = mockRes(); + + await handleListCloudWatchTraces(ctx, req, res); + + expect(res._status).toBe(500); + const body = JSON.parse(res._body); + expect(body.success).toBe(false); + expect(body.error).toContain('Failed to list CloudWatch traces'); + }); + + it('returns 400 for invalid startTime', async () => { + const handler = vi.fn(); + const ctx = mockCtx({ onListCloudWatchTraces: handler }); + const req = mockReq('/api/cloudwatch-traces?agentName=my-agent&startTime=notanumber'); + const res = mockRes(); + + await handleListCloudWatchTraces(ctx, req, res); + + expect(res._status).toBe(400); + const body = JSON.parse(res._body); + expect(body.success).toBe(false); + expect(body.error).toContain('startTime'); + expect(handler).not.toHaveBeenCalled(); + }); +}); + +describe('handleGetCloudWatchTrace', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns 404 when no handler configured', async () => { + const ctx = mockCtx(); + const req = mockReq('/api/cloudwatch-traces/abc123?agentName=my-agent'); + const res = mockRes(); + + await handleGetCloudWatchTrace(ctx, req, res); + + expect(res._status).toBe(404); + const body = JSON.parse(res._body); + expect(body.success).toBe(false); + expect(body.error).toContain('not available'); + }); + + it('returns 400 when traceId is missing', async () => { + const handler = vi.fn(); + const ctx = mockCtx({ onGetCloudWatchTrace: handler }); + const req = mockReq('/api/cloudwatch-traces/?agentName=my-agent'); + const res = mockRes(); + + await handleGetCloudWatchTrace(ctx, req, res); + + expect(res._status).toBe(400); + const body = JSON.parse(res._body); + expect(body.success).toBe(false); + expect(body.error).toContain('traceId'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('returns 400 when neither agentName nor harnessName provided', async () => { + const handler = vi.fn(); + const ctx = mockCtx({ onGetCloudWatchTrace: handler }); + const req = mockReq('/api/cloudwatch-traces/abc123'); + const res = mockRes(); + + await handleGetCloudWatchTrace(ctx, req, res); + + expect(res._status).toBe(400); + const body = JSON.parse(res._body); + expect(body.success).toBe(false); + expect(body.error).toContain('agentName'); + expect(body.error).toContain('harnessName'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('returns 500 when handler throws', async () => { + const handler = vi.fn().mockRejectedValue(new Error('boom')); + const ctx = mockCtx({ onGetCloudWatchTrace: handler }); + const req = mockReq('/api/cloudwatch-traces/abc123?agentName=my-agent'); + const res = mockRes(); + + await handleGetCloudWatchTrace(ctx, req, res); + + expect(res._status).toBe(500); + const body = JSON.parse(res._body); + expect(body.success).toBe(false); + expect(body.error).toContain('Failed to get CloudWatch trace'); + }); + + it('calls handler and returns records', async () => { + const records = [{ record: 'data1' }]; + const handler = vi.fn().mockResolvedValue({ success: true, records }); + const ctx = mockCtx({ onGetCloudWatchTrace: handler }); + const req = mockReq('/api/cloudwatch-traces/abc123?agentName=my-agent'); + const res = mockRes(); + + await handleGetCloudWatchTrace(ctx, req, res); + + expect(res._status).toBe(200); + expect(handler).toHaveBeenCalledWith('my-agent', undefined, 'abc123', undefined, undefined); + const body = JSON.parse(res._body); + expect(body.success).toBe(true); + expect(body.records).toEqual(records); + }); +}); diff --git a/src/cli/operations/dev/web-ui/api-types.ts b/src/cli/operations/dev/web-ui/api-types.ts index 509d834ff..8ba57937e 100644 --- a/src/cli/operations/dev/web-ui/api-types.ts +++ b/src/cli/operations/dev/web-ui/api-types.ts @@ -8,6 +8,7 @@ * TODO: Extract these types into a shared package so both repos import * from a single source of truth instead of manually duplicating. */ +import type { CloudWatchSpanRecord, CloudWatchTraceRecord } from '../../traces/types'; // --------------------------------------------------------------------------- // GET /api/status @@ -279,6 +280,39 @@ export interface GetTraceResponse { error?: string; } +// --------------------------------------------------------------------------- +// GET /api/cloudwatch-traces?agentName=xxx|harnessName=xxx +// --------------------------------------------------------------------------- + +/** A single trace entry returned by the CloudWatch traces list endpoint */ +export interface CloudWatchTraceEntry { + traceId: string; + timestamp: string; + sessionId?: string; + spanCount?: string; +} + +/** Response shape for GET /api/cloudwatch-traces */ +export interface ListCloudWatchTracesResponse { + success: boolean; + traces?: CloudWatchTraceEntry[]; + error?: string; +} + +// --------------------------------------------------------------------------- +// GET /api/cloudwatch-traces/:traceId?agentName=xxx|harnessName=xxx +// --------------------------------------------------------------------------- + +/** Response shape for GET /api/cloudwatch-traces/:traceId */ +export interface GetCloudWatchTraceResponse { + success: boolean; + records?: CloudWatchTraceRecord[]; + spans?: CloudWatchSpanRecord[]; + error?: string; +} + +export type { CloudWatchTraceRecord, CloudWatchSpanRecord } from '../../traces/types'; + // --------------------------------------------------------------------------- // GET /api/memory?memoryName=xxx&namespace=yyy[&strategyId=zzz] // --------------------------------------------------------------------------- diff --git a/src/cli/operations/dev/web-ui/handlers/cloudwatch-traces.ts b/src/cli/operations/dev/web-ui/handlers/cloudwatch-traces.ts new file mode 100644 index 000000000..15759b766 --- /dev/null +++ b/src/cli/operations/dev/web-ui/handlers/cloudwatch-traces.ts @@ -0,0 +1,166 @@ +import type { RouteContext } from './route-context'; +import { parseRequestUrl } from './route-context'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +/** + * GET /api/cloudwatch-traces?agentName=xxx or ?harnessName=xxx — list recent CloudWatch traces. + * Exactly one of agentName or harnessName must be provided. + */ +export async function handleListCloudWatchTraces( + ctx: RouteContext, + req: IncomingMessage, + res: ServerResponse, + origin?: string +): Promise { + const { param } = parseRequestUrl(req); + const handler = ctx.options.onListCloudWatchTraces; + + if (!handler) { + ctx.setCorsHeaders(res, origin); + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'CloudWatch traces are not available' })); + return; + } + + const agentName = param('agentName'); + const harnessName = param('harnessName'); + + if (!agentName && !harnessName) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'Either agentName or harnessName query parameter is required' })); + return; + } + + if (agentName && harnessName) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + success: false, + error: 'Provide either agentName or harnessName, not both', + }) + ); + return; + } + + // Parse optional date range query params (epoch milliseconds) + const startTimeRaw = param('startTime'); + const endTimeRaw = param('endTime'); + const startTime = startTimeRaw ? Number(startTimeRaw) : undefined; + const endTime = endTimeRaw ? Number(endTimeRaw) : undefined; + + if (startTimeRaw && isNaN(startTime!)) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'startTime must be a number (epoch milliseconds)' })); + return; + } + if (endTimeRaw && isNaN(endTime!)) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'endTime must be a number (epoch milliseconds)' })); + return; + } + + try { + const result = await handler(agentName, harnessName, startTime, endTime); + ctx.setCorsHeaders(res, origin); + res.writeHead(result.success ? 200 : 500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + } catch (err) { + ctx.options.onLog?.('error', `List CloudWatch traces error: ${err instanceof Error ? err.message : String(err)}`); + ctx.setCorsHeaders(res, origin); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'Failed to list CloudWatch traces' })); + } +} + +/** + * GET /api/cloudwatch-traces/:traceId?agentName=xxx or ?harnessName=xxx — get full CloudWatch trace data. + * Exactly one of agentName or harnessName must be provided. + */ +export async function handleGetCloudWatchTrace( + ctx: RouteContext, + req: IncomingMessage, + res: ServerResponse, + origin?: string +): Promise { + const { pathname, param } = parseRequestUrl(req); + const handler = ctx.options.onGetCloudWatchTrace; + + if (!handler) { + ctx.setCorsHeaders(res, origin); + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'CloudWatch traces are not available' })); + return; + } + + const traceId = pathname.replace('/api/cloudwatch-traces/', ''); + const agentName = param('agentName'); + const harnessName = param('harnessName'); + + if (!traceId) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'traceId is required in the URL path' })); + return; + } + + if (!/^[a-fA-F0-9-]+$/.test(traceId)) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'Invalid trace ID format' })); + return; + } + + if (!agentName && !harnessName) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'Either agentName or harnessName query parameter is required' })); + return; + } + + if (agentName && harnessName) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + success: false, + error: 'Provide either agentName or harnessName, not both', + }) + ); + return; + } + + // Parse optional date range query params (epoch milliseconds) + const startTimeRaw = param('startTime'); + const endTimeRaw = param('endTime'); + const startTime = startTimeRaw ? Number(startTimeRaw) : undefined; + const endTime = endTimeRaw ? Number(endTimeRaw) : undefined; + + if (startTimeRaw && isNaN(startTime!)) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'startTime must be a number (epoch milliseconds)' })); + return; + } + if (endTimeRaw && isNaN(endTime!)) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'endTime must be a number (epoch milliseconds)' })); + return; + } + + try { + const result = await handler(agentName, harnessName, traceId, startTime, endTime); + ctx.setCorsHeaders(res, origin); + res.writeHead(result.success ? 200 : 500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + } catch (err) { + ctx.options.onLog?.('error', `Get CloudWatch trace error: ${err instanceof Error ? err.message : String(err)}`); + ctx.setCorsHeaders(res, origin); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'Failed to get CloudWatch trace' })); + } +} diff --git a/src/cli/operations/dev/web-ui/handlers/index.ts b/src/cli/operations/dev/web-ui/handlers/index.ts index 91d2d4d5d..0ae7b4f67 100644 --- a/src/cli/operations/dev/web-ui/handlers/index.ts +++ b/src/cli/operations/dev/web-ui/handlers/index.ts @@ -4,6 +4,7 @@ export { handleResources } from './resources'; export { handleStart } from './start'; export { handleInvocations } from './invocations'; export { handleListTraces, handleGetTrace } from './traces'; +export { handleListCloudWatchTraces, handleGetCloudWatchTrace } from './cloudwatch-traces'; export { handleListMemoryRecords, handleRetrieveMemoryRecords } from './memory'; export { handleMcpProxy } from './mcp-proxy'; export { handleA2AAgentCard } from './a2a-proxy'; diff --git a/src/cli/operations/dev/web-ui/index.ts b/src/cli/operations/dev/web-ui/index.ts index 6901eb31a..b14949008 100644 --- a/src/cli/operations/dev/web-ui/index.ts +++ b/src/cli/operations/dev/web-ui/index.ts @@ -4,6 +4,8 @@ export { type StartHandler, type ListTracesHandler, type GetTraceHandler, + type ListCloudWatchTracesHandler, + type GetCloudWatchTraceHandler, type ListMemoryRecordsHandler, type RetrieveMemoryRecordsHandler, } from './web-server'; @@ -29,6 +31,11 @@ export type { InvocationRequest, ListTracesResponse, GetTraceResponse, + ListCloudWatchTracesResponse, + CloudWatchTraceEntry, + GetCloudWatchTraceResponse, + CloudWatchTraceRecord, + CloudWatchSpanRecord, ListMemoryRecordsResponse, MemoryRecordResponse, RetrieveMemoryRecordsRequest, diff --git a/src/cli/operations/dev/web-ui/web-server.ts b/src/cli/operations/dev/web-ui/web-server.ts index c3f9c6f36..2b20b2d07 100644 --- a/src/cli/operations/dev/web-ui/web-server.ts +++ b/src/cli/operations/dev/web-ui/web-server.ts @@ -4,8 +4,10 @@ import { type AgentError, type AgentInfo, WEB_UI_LOCAL_URL } from './constants'; import { type RouteContext, handleA2AAgentCard, + handleGetCloudWatchTrace, handleGetTrace, handleInvocations, + handleListCloudWatchTraces, handleListMemoryRecords, handleListTraces, handleMcpProxy, @@ -78,6 +80,29 @@ export type GetTraceHandler = ( endTime?: number ) => Promise<{ success: boolean; resourceSpans?: unknown[]; resourceLogs?: unknown[]; error?: string }>; +/** + * Custom handler for GET /api/cloudwatch-traces. + * Returns a list of recent CloudWatch traces for the given agent or harness. + */ +export type ListCloudWatchTracesHandler = ( + agentName: string | undefined, + harnessName: string | undefined, + startTime?: number, + endTime?: number +) => Promise<{ success: boolean; traces?: unknown[]; error?: string }>; + +/** + * Custom handler for GET /api/cloudwatch-traces/:traceId. + * Returns the full CloudWatch trace data for a specific trace. + */ +export type GetCloudWatchTraceHandler = ( + agentName: string | undefined, + harnessName: string | undefined, + traceId: string, + startTime?: number, + endTime?: number +) => Promise<{ success: boolean; records?: unknown[]; spans?: unknown[]; error?: string }>; + /** * Custom handler for GET /api/memory. * Returns a list of memory records for a given memory + namespace. @@ -124,6 +149,10 @@ export interface WebUIOptions { onListTraces?: ListTracesHandler; /** Custom handler for getting a single trace */ onGetTrace?: GetTraceHandler; + /** Custom handler for listing CloudWatch traces */ + onListCloudWatchTraces?: ListCloudWatchTracesHandler; + /** Custom handler for getting a single CloudWatch trace */ + onGetCloudWatchTrace?: GetCloudWatchTraceHandler; /** Custom handler for listing memory records */ onListMemoryRecords?: ListMemoryRecordsHandler; /** Custom handler for searching memory records */ @@ -291,6 +320,10 @@ export class WebUIServer { await handleGetTrace(ctx, req, res, origin); } else if (req.method === 'GET' && req.url?.startsWith('/api/traces')) { await handleListTraces(ctx, req, res, origin); + } else if (req.method === 'GET' && req.url?.startsWith('/api/cloudwatch-traces/')) { + await handleGetCloudWatchTrace(ctx, req, res, origin); + } else if (req.method === 'GET' && req.url?.startsWith('/api/cloudwatch-traces')) { + await handleListCloudWatchTraces(ctx, req, res, origin); } else if (req.method === 'POST' && req.url === '/api/start') { await handleStart(ctx, req, res, origin); } else if (req.method === 'POST' && req.url === '/invocations') { diff --git a/src/cli/operations/traces/__tests__/get-trace.test.ts b/src/cli/operations/traces/__tests__/get-trace.test.ts new file mode 100644 index 000000000..c6fda22f4 --- /dev/null +++ b/src/cli/operations/traces/__tests__/get-trace.test.ts @@ -0,0 +1,233 @@ +import { fetchTraceRecords, getTrace } from '../get-trace'; +import type { FetchTraceRecordsOptions } from '../types'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockSend } = vi.hoisted(() => ({ + mockSend: vi.fn(), +})); + +vi.mock('@aws-sdk/client-cloudwatch-logs', () => ({ + CloudWatchLogsClient: class { + send = mockSend; + }, + StartQueryCommand: class { + constructor(public input: unknown) {} + }, + GetQueryResultsCommand: class { + constructor(public input: unknown) {} + }, +})); + +vi.mock('../../../aws', () => ({ + getCredentialProvider: vi.fn().mockReturnValue({}), +})); + +vi.mock('node:fs', () => ({ + default: { + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + }, +})); + +const baseOptions: FetchTraceRecordsOptions = { + region: 'us-west-2', + runtimeId: 'runtime-123', + traceId: 'abc123def456', + startTime: 1000000, + endTime: 2000000, +}; + +describe('fetchTraceRecords', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns parsed trace records from CloudWatch', async () => { + mockSend + .mockResolvedValueOnce({ queryId: 'q-1' }) // StartQueryCommand + .mockResolvedValueOnce({ + // GetQueryResultsCommand + status: 'Complete', + results: [ + [ + { field: '@timestamp', value: '2024-01-01T00:00:00Z' }, + { field: '@message', value: '{"traceId":"abc123","spanId":"span1"}' }, + { field: '@ptr', value: 'ptr-value-1' }, + ], + [ + { field: '@timestamp', value: '2024-01-01T00:00:01Z' }, + { field: '@message', value: '{"traceId":"abc123","spanId":"span2"}' }, + ], + ], + }); + + const result = await fetchTraceRecords(baseOptions); + + expect(result.success).toBe(true); + expect(result.records).toHaveLength(2); + expect(result.records![0]).toEqual({ + '@timestamp': '2024-01-01T00:00:00Z', + '@message': { traceId: 'abc123', spanId: 'span1' }, + '@ptr': 'ptr-value-1', + }); + expect(result.records![1]).toEqual({ + '@timestamp': '2024-01-01T00:00:01Z', + '@message': { traceId: 'abc123', spanId: 'span2' }, + }); + }); + + it('returns error for invalid trace ID format', async () => { + const result = await fetchTraceRecords({ + ...baseOptions, + traceId: 'invalid!@#$', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid trace ID format'); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it('returns error when no trace data found', async () => { + mockSend.mockResolvedValueOnce({ queryId: 'q-1' }).mockResolvedValueOnce({ + status: 'Complete', + results: [], + }); + + const result = await fetchTraceRecords(baseOptions); + + expect(result.success).toBe(false); + expect(result.error).toContain('No trace data found'); + }); + + it('returns error when query fails to start', async () => { + mockSend.mockResolvedValueOnce({ queryId: undefined }); + + const result = await fetchTraceRecords(baseOptions); + + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to start CloudWatch Logs Insights query'); + }); + + it('returns error when query status is Failed', async () => { + mockSend.mockResolvedValueOnce({ queryId: 'q-1' }).mockResolvedValueOnce({ status: 'Failed' }); + + const result = await fetchTraceRecords(baseOptions); + + expect(result.success).toBe(false); + expect(result.error).toContain('failed'); + }); + + it('preserves @ptr when present in CloudWatch response', async () => { + mockSend.mockResolvedValueOnce({ queryId: 'q-1' }).mockResolvedValueOnce({ + status: 'Complete', + results: [ + [ + { field: '@timestamp', value: '2024-01-01T00:00:00Z' }, + { field: '@message', value: '{"key":"val"}' }, + { field: '@ptr', value: 'cw-ptr-123' }, + ], + ], + }); + + const result = await fetchTraceRecords(baseOptions); + + expect(result.success).toBe(true); + expect(result.records).toHaveLength(1); + expect(result.records![0]!['@ptr']).toBe('cw-ptr-123'); + }); + + it('omits @ptr when not present in CloudWatch response', async () => { + mockSend.mockResolvedValueOnce({ queryId: 'q-1' }).mockResolvedValueOnce({ + status: 'Complete', + results: [ + [ + { field: '@timestamp', value: '2024-01-01T00:00:00Z' }, + { field: '@message', value: '{"key":"val"}' }, + ], + ], + }); + + const result = await fetchTraceRecords(baseOptions); + + expect(result.success).toBe(true); + expect(result.records![0]).not.toHaveProperty('@ptr'); + }); + + it('handles non-JSON @message gracefully', async () => { + mockSend.mockResolvedValueOnce({ queryId: 'q-1' }).mockResolvedValueOnce({ + status: 'Complete', + results: [ + [ + { field: '@timestamp', value: '2024-01-01T00:00:00Z' }, + { field: '@message', value: 'plain text message' }, + ], + ], + }); + + const result = await fetchTraceRecords(baseOptions); + + expect(result.success).toBe(true); + expect(result.records).toHaveLength(1); + expect(result.records![0]!['@message']).toBe('plain text message'); + }); + + it('handles ResourceNotFoundException', async () => { + const error = new Error('Not found'); + error.name = 'ResourceNotFoundException'; + mockSend.mockRejectedValueOnce(error); + + const result = await fetchTraceRecords(baseOptions); + + expect(result.success).toBe(false); + expect(result.error).toContain('Log group'); + expect(result.error).toContain('not found'); + }); +}); + +describe('getTrace', () => { + afterEach(() => vi.clearAllMocks()); + + it('calls fetchTraceRecords and writes result to disk', async () => { + const fs = await import('node:fs'); + + mockSend.mockResolvedValueOnce({ queryId: 'q-1' }).mockResolvedValueOnce({ + status: 'Complete', + results: [ + [ + { field: '@timestamp', value: '2024-01-01T00:00:00Z' }, + { field: '@message', value: '{"traceId":"abc123"}' }, + ], + ], + }); + + const result = await getTrace({ + region: 'us-west-2', + runtimeId: 'runtime-123', + agentName: 'my-agent', + traceId: 'abc123def456', + outputPath: '/tmp/test-trace.json', + startTime: 1000000, + endTime: 2000000, + }); + + expect(result.success).toBe(true); + expect(result.filePath).toContain('test-trace.json'); + expect(fs.default.mkdirSync).toHaveBeenCalled(); + expect(fs.default.writeFileSync).toHaveBeenCalledWith('/tmp/test-trace.json', expect.stringContaining('"traceId"')); + }); + + it('returns error from fetchTraceRecords without writing file', async () => { + const fs = await import('node:fs'); + + const result = await getTrace({ + region: 'us-west-2', + runtimeId: 'runtime-123', + agentName: 'my-agent', + traceId: 'invalid!@#$', + startTime: 1000000, + endTime: 2000000, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid trace ID format'); + expect(fs.default.writeFileSync).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli/operations/traces/__tests__/list-traces.test.ts b/src/cli/operations/traces/__tests__/list-traces.test.ts new file mode 100644 index 000000000..0bbe884de --- /dev/null +++ b/src/cli/operations/traces/__tests__/list-traces.test.ts @@ -0,0 +1,135 @@ +import { listTraces } from '../list-traces'; +import type { ListTracesOptions } from '../types'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockRunInsightsQuery } = vi.hoisted(() => ({ + mockRunInsightsQuery: vi.fn(), +})); + +vi.mock('../insights-query', () => ({ + runInsightsQuery: mockRunInsightsQuery, +})); + +const baseOptions: ListTracesOptions = { + region: 'us-west-2', + runtimeId: 'runtime-123', + agentName: 'my-agent', + startTime: 1000000, + endTime: 2000000, +}; + +describe('listTraces', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns trace entries from query results', async () => { + mockRunInsightsQuery.mockResolvedValueOnce({ + success: true, + rows: [ + { + traceId: 'trace-1', + lastSeen: '2024-01-01T00:05:00Z', + firstSeen: '2024-01-01T00:00:00Z', + spanCount: '12', + sessionId: 'sess-1', + }, + { traceId: 'trace-2', lastSeen: '2024-01-01T00:03:00Z', firstSeen: '2024-01-01T00:01:00Z', spanCount: '5' }, + ], + }); + + const result = await listTraces(baseOptions); + + expect(result.success).toBe(true); + expect(result.traces).toHaveLength(2); + expect(result.traces![0]).toEqual({ + traceId: 'trace-1', + timestamp: '2024-01-01T00:05:00Z', + sessionId: 'sess-1', + spanCount: '12', + }); + expect(result.traces![1]).toEqual({ + traceId: 'trace-2', + timestamp: '2024-01-01T00:03:00Z', + sessionId: undefined, + spanCount: '5', + }); + }); + + it('filters out rows without traceId', async () => { + mockRunInsightsQuery.mockResolvedValueOnce({ + success: true, + rows: [ + { traceId: 'trace-1', lastSeen: '2024-01-01T00:00:00Z', spanCount: '3' }, + { lastSeen: '2024-01-01T00:00:00Z', spanCount: '1' }, + { traceId: '', lastSeen: '2024-01-01T00:00:00Z', spanCount: '2' }, + ], + }); + + const result = await listTraces(baseOptions); + + expect(result.success).toBe(true); + expect(result.traces).toHaveLength(1); + expect(result.traces![0]!.traceId).toBe('trace-1'); + }); + + it('falls back to firstSeen when lastSeen is missing', async () => { + mockRunInsightsQuery.mockResolvedValueOnce({ + success: true, + rows: [{ traceId: 'trace-1', firstSeen: '2024-01-01T00:00:00Z', spanCount: '1' }], + }); + + const result = await listTraces(baseOptions); + + expect(result.success).toBe(true); + expect(result.traces![0]!.timestamp).toBe('2024-01-01T00:00:00Z'); + }); + + it('returns empty traces for empty query results', async () => { + mockRunInsightsQuery.mockResolvedValueOnce({ + success: true, + rows: [], + }); + + const result = await listTraces(baseOptions); + + expect(result.success).toBe(true); + expect(result.traces).toHaveLength(0); + }); + + it('propagates errors from runInsightsQuery', async () => { + mockRunInsightsQuery.mockResolvedValueOnce({ + success: false, + error: 'Log group not found', + }); + + const result = await listTraces(baseOptions); + + expect(result.success).toBe(false); + expect(result.error).toBe('Log group not found'); + }); + + it('passes correct log group name and default limit', async () => { + mockRunInsightsQuery.mockResolvedValueOnce({ success: true, rows: [] }); + + await listTraces(baseOptions); + + expect(mockRunInsightsQuery).toHaveBeenCalledWith({ + region: 'us-west-2', + logGroupName: '/aws/bedrock-agentcore/runtimes/runtime-123-DEFAULT', + startTime: 1000000, + endTime: 2000000, + queryString: expect.stringContaining('limit 20'), + }); + }); + + it('respects custom limit', async () => { + mockRunInsightsQuery.mockResolvedValueOnce({ success: true, rows: [] }); + + await listTraces({ ...baseOptions, limit: 50 }); + + expect(mockRunInsightsQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryString: expect.stringContaining('limit 50'), + }) + ); + }); +}); diff --git a/src/cli/operations/traces/get-trace.ts b/src/cli/operations/traces/get-trace.ts index 85c4471be..a87f10a65 100644 --- a/src/cli/operations/traces/get-trace.ts +++ b/src/cli/operations/traces/get-trace.ts @@ -1,129 +1,186 @@ -import { getCredentialProvider } from '../../aws'; import { DEFAULT_ENDPOINT_NAME } from '../../constants'; -import { CloudWatchLogsClient, GetQueryResultsCommand, StartQueryCommand } from '@aws-sdk/client-cloudwatch-logs'; +import { runInsightsQuery } from './insights-query'; +import type { + CloudWatchSpanRecord, + CloudWatchTraceRecord, + FetchTraceRecordsOptions, + FetchTraceRecordsResult, + GetTraceOptions, + GetTraceResult, +} from './types'; import fs from 'node:fs'; import path from 'node:path'; -export interface GetTraceOptions { - region: string; - runtimeId: string; - agentName: string; - traceId: string; - outputPath?: string; - startTime?: number; - endTime?: number; -} +const SPANS_LOG_GROUP = 'aws/spans'; +const TRACE_ID_PATTERN = /^[a-fA-F0-9-]+$/; -export interface GetTraceResult { - success: boolean; - filePath?: string; - error?: string; +function runtimeLogGroup(runtimeId: string): string { + return `/aws/bedrock-agentcore/runtimes/${runtimeId}-${DEFAULT_ENDPOINT_NAME}`; } -/** - * Fetches a full trace from CloudWatch Logs and writes it to a JSON file. - * - * Log group naming convention: /aws/bedrock-agentcore/runtimes/{runtimeId}-DEFAULT - * Trace ID is stored in the @message JSON body as "traceId". - */ -export async function getTrace(options: GetTraceOptions): Promise { - const { region, runtimeId, agentName, traceId, outputPath } = options; - - if (!/^[a-fA-F0-9-]+$/.test(traceId)) { +async function fetchSpans( + region: string, + traceId: string, + startTime?: number, + endTime?: number +): Promise<{ success: boolean; spans?: CloudWatchSpanRecord[]; error?: string }> { + if (!TRACE_ID_PATTERN.test(traceId)) { return { success: false, error: 'Invalid trace ID format. Expected a hex string (e.g., abc123def456).' }; } - const client = new CloudWatchLogsClient({ - credentials: getCredentialProvider(), + const result = await runInsightsQuery({ region, + logGroupName: SPANS_LOG_GROUP, + startTime, + endTime, + queryString: `fields traceId, spanId, parentSpanId, name, kind, + startTimeUnixNano, endTimeUnixNano, durationNano, + status.code as statusCode, + resource.attributes.service.name as serviceName, + attributes.gen_ai.usage.input_tokens as inputTokens, + attributes.gen_ai.usage.output_tokens as outputTokens, + attributes.gen_ai.usage.total_tokens as totalTokens, + attributes.http.status_code as httpStatusCode, + attributes.session.id as sessionId +| filter ispresent(traceId) and ispresent(resource.attributes.service.name) +| filter resource.attributes.aws.service.type = "gen_ai_agent" +| filter traceId = '${traceId}' +| sort startTimeUnixNano asc`, }); - const logGroupName = `/aws/bedrock-agentcore/runtimes/${runtimeId}-${DEFAULT_ENDPOINT_NAME}`; + if (!result.success) return { success: false, error: result.error }; + + const spans: CloudWatchSpanRecord[] = (result.rows ?? []) + .filter(row => row.traceId && row.spanId) + .map(row => ({ + traceId: row.traceId!, + spanId: row.spanId!, + parentSpanId: row.parentSpanId ?? undefined, + name: row.name ?? undefined, + kind: row.kind ?? undefined, + startTimeUnixNano: row.startTimeUnixNano ?? undefined, + endTimeUnixNano: row.endTimeUnixNano ?? undefined, + durationNano: row.durationNano ?? undefined, + statusCode: row.statusCode ?? undefined, + serviceName: row.serviceName ?? undefined, + inputTokens: row.inputTokens ? Number(row.inputTokens) : undefined, + outputTokens: row.outputTokens ? Number(row.outputTokens) : undefined, + totalTokens: row.totalTokens ? Number(row.totalTokens) : undefined, + httpStatusCode: row.httpStatusCode ? Number(row.httpStatusCode) : undefined, + sessionId: row.sessionId ?? undefined, + })); + + return { success: true, spans }; +} + +/** + * Fetches trace records from CloudWatch Logs Insights for a given trace ID. + * Returns typed records for the web UI API. Use `getTrace()` to write raw + * results to a JSON file on disk. + */ +export async function fetchTraceRecords(options: FetchTraceRecordsOptions): Promise { + const { region, runtimeId, traceId, includeSpans } = options; - const now = Date.now(); - const endTime = options.endTime ?? now; - const startTime = options.startTime ?? endTime - 12 * 60 * 60 * 1000; // default: last 12 hours + if (!TRACE_ID_PATTERN.test(traceId)) { + return { success: false, error: 'Invalid trace ID format. Expected a hex string (e.g., abc123def456).' }; + } - try { - const startQuery = await client.send( - new StartQueryCommand({ - logGroupName, - startTime: Math.floor(startTime / 1000), - endTime: Math.floor(endTime / 1000), - queryString: `fields @timestamp, @message + const [recordsResult, spansResult] = await Promise.all([ + runInsightsQuery({ + region, + logGroupName: runtimeLogGroup(runtimeId), + startTime: options.startTime, + endTime: options.endTime, + queryString: `fields @timestamp, @message, @ptr | filter traceId = '${traceId}' | sort @timestamp asc -| limit 1000`, - }) - ); +| limit 10000`, + }), + includeSpans ? fetchSpans(region, traceId, options.startTime, options.endTime) : Promise.resolve(undefined), + ]); - if (!startQuery.queryId) { - return { success: false, error: 'Failed to start CloudWatch Logs Insights query' }; - } + if (!recordsResult.success) { + return { success: false, error: recordsResult.error }; + } - // Poll for results - let traceData: Record[] = []; - let queryStatus = 'Running'; - - for (let i = 0; i < 60; i++) { - await new Promise(resolve => setTimeout(resolve, 1000)); - - const queryResults = await client.send(new GetQueryResultsCommand({ queryId: startQuery.queryId })); - - queryStatus = queryResults.status ?? 'Unknown'; - - if (queryStatus === 'Complete' || queryStatus === 'Failed' || queryStatus === 'Cancelled') { - if (queryStatus !== 'Complete') { - return { success: false, error: `Query ${queryStatus.toLowerCase()}` }; - } - - traceData = (queryResults.results ?? []).map(row => { - const fields: Record = {}; - for (const field of row) { - if (field.field && field.value) { - fields[field.field] = field.value; - } - } - return fields; - }); - break; - } - } + const traceData = recordsResult.rows ?? []; - if (queryStatus === 'Running') { - return { success: false, error: 'Query timed out after 60 seconds' }; - } + if (traceData.length === 0 && (!spansResult || (spansResult.spans ?? []).length === 0)) { + return { success: false, error: `No trace data found for trace ID: ${traceId}` }; + } - if (traceData.length === 0) { - return { success: false, error: `No trace data found for trace ID: ${traceId}` }; + const records: CloudWatchTraceRecord[] = traceData.map(entry => { + let message: unknown = entry['@message'] ?? '{}'; + try { + message = JSON.parse(entry['@message'] ?? '{}'); + } catch { + // Keep original string if not valid JSON } - // Parse @message fields as JSON where possible - const parsedTrace = traceData.map(entry => { - try { - const parsed: unknown = JSON.parse(entry['@message'] ?? '{}'); - return { ...entry, '@message': parsed }; - } catch { - return entry; - } - }); - - // Write to file - const filePath = outputPath ?? path.join('agentcore', '.cli', 'traces', `${agentName}-${traceId}.json`); - - const dir = path.dirname(filePath); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(filePath, JSON.stringify(parsedTrace, null, 2)); - - return { success: true, filePath: path.resolve(filePath) }; - } catch (error: unknown) { - const err = error as Error; - if (err.name === 'ResourceNotFoundException') { - return { - success: false, - error: `Log group '${logGroupName}' not found. The agent may not have been invoked yet, or traces may not be enabled.`, - }; + const record: CloudWatchTraceRecord = { + '@timestamp': entry['@timestamp'] ?? '', + '@message': message, + }; + + if (entry['@ptr']) { + record['@ptr'] = entry['@ptr']; } - return { success: false, error: err.message ?? String(error) }; + + return record; + }); + + const result: FetchTraceRecordsResult = { success: true, records }; + + if (spansResult?.success && spansResult.spans) { + result.spans = spansResult.spans; } + + return result; +} + +/** + * Fetches a full trace from CloudWatch Logs and writes it to a JSON file. + * Preserves all raw CloudWatch Insights fields in the output file. + */ +export async function getTrace(options: GetTraceOptions): Promise { + const { region, runtimeId, agentName, traceId, outputPath } = options; + + if (!TRACE_ID_PATTERN.test(traceId)) { + return { success: false, error: 'Invalid trace ID format. Expected a hex string (e.g., abc123def456).' }; + } + + const result = await runInsightsQuery({ + region, + logGroupName: runtimeLogGroup(runtimeId), + startTime: options.startTime, + endTime: options.endTime, + queryString: `fields @timestamp, @message +| filter traceId = '${traceId}' +| sort @timestamp asc +| limit 10000`, + }); + if (!result.success) { + return { success: false, error: result.error }; + } + + const traceData = result.rows ?? []; + if (traceData.length === 0) { + return { success: false, error: `No trace data found for trace ID: ${traceId}` }; + } + + const parsedTrace = traceData.map(entry => { + try { + const parsed: unknown = JSON.parse(entry['@message'] ?? '{}'); + return { ...entry, '@message': parsed }; + } catch { + return entry; + } + }); + + const filePath = outputPath ?? path.join('agentcore', '.cli', 'traces', `${agentName}-${traceId}.json`); + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(parsedTrace, null, 2)); + + return { success: true, filePath: path.resolve(filePath) }; } diff --git a/src/cli/operations/traces/index.ts b/src/cli/operations/traces/index.ts index bbb013439..cf19dbf9c 100644 --- a/src/cli/operations/traces/index.ts +++ b/src/cli/operations/traces/index.ts @@ -1,3 +1,15 @@ export { buildTraceConsoleUrl } from './trace-url'; -export { listTraces, type TraceEntry, type ListTracesOptions, type ListTracesResult } from './list-traces'; -export { getTrace, type GetTraceOptions, type GetTraceResult } from './get-trace'; +export { listTraces } from './list-traces'; +export { fetchTraceRecords, getTrace } from './get-trace'; +export { runInsightsQuery, type InsightsQueryOptions, type InsightsQueryResult } from './insights-query'; +export type { + CloudWatchSpanRecord, + CloudWatchTraceRecord, + FetchTraceRecordsOptions, + FetchTraceRecordsResult, + GetTraceOptions, + GetTraceResult, + ListTracesOptions, + ListTracesResult, + TraceEntry, +} from './types'; diff --git a/src/cli/operations/traces/insights-query.ts b/src/cli/operations/traces/insights-query.ts new file mode 100644 index 000000000..5a4da2031 --- /dev/null +++ b/src/cli/operations/traces/insights-query.ts @@ -0,0 +1,85 @@ +import { getCredentialProvider } from '../../aws'; +import { CloudWatchLogsClient, GetQueryResultsCommand, StartQueryCommand } from '@aws-sdk/client-cloudwatch-logs'; + +const DEFAULT_LOOKBACK_MS = 12 * 60 * 60 * 1000; + +export interface InsightsQueryOptions { + region: string; + logGroupName: string; + queryString: string; + startTime?: number; + endTime?: number; +} + +export interface InsightsQueryResult { + success: boolean; + rows?: Record[]; + error?: string; +} + +async function pollQueryResults(client: CloudWatchLogsClient, queryId: string): Promise { + for (let i = 0; i < 60; i++) { + await new Promise(resolve => setTimeout(resolve, 1000)); + + const queryResults = await client.send(new GetQueryResultsCommand({ queryId })); + const status = queryResults.status ?? 'Unknown'; + + if (status === 'Complete' || status === 'Failed' || status === 'Cancelled') { + if (status !== 'Complete') { + return { success: false, error: `Query ${status.toLowerCase()}` }; + } + + const rows = (queryResults.results ?? []).map(row => { + const fields: Record = {}; + for (const field of row) { + if (field.field && field.value) { + fields[field.field] = field.value; + } + } + return fields; + }); + return { success: true, rows }; + } + } + + return { success: false, error: 'Query timed out after 60 seconds' }; +} + +export async function runInsightsQuery(options: InsightsQueryOptions): Promise { + const { region, logGroupName, queryString } = options; + + const client = new CloudWatchLogsClient({ + credentials: getCredentialProvider(), + region, + }); + + const now = Date.now(); + const endTime = options.endTime ?? now; + const startTime = options.startTime ?? endTime - DEFAULT_LOOKBACK_MS; + + try { + const startQuery = await client.send( + new StartQueryCommand({ + logGroupName, + startTime: Math.floor(startTime / 1000), + endTime: Math.floor(endTime / 1000), + queryString, + }) + ); + + if (!startQuery.queryId) { + return { success: false, error: 'Failed to start CloudWatch Logs Insights query' }; + } + + return await pollQueryResults(client, startQuery.queryId); + } catch (error: unknown) { + const err = error as Error; + if (err.name === 'ResourceNotFoundException') { + return { + success: false, + error: `Log group '${logGroupName}' not found. The agent may not have been invoked yet, or traces may not be enabled.`, + }; + } + return { success: false, error: err.message ?? String(error) }; + } +} diff --git a/src/cli/operations/traces/list-traces.ts b/src/cli/operations/traces/list-traces.ts index 7bff6194a..e2d998578 100644 --- a/src/cli/operations/traces/list-traces.ts +++ b/src/cli/operations/traces/list-traces.ts @@ -1,28 +1,6 @@ -import { getCredentialProvider } from '../../aws'; import { DEFAULT_ENDPOINT_NAME } from '../../constants'; -import { CloudWatchLogsClient, GetQueryResultsCommand, StartQueryCommand } from '@aws-sdk/client-cloudwatch-logs'; - -export interface TraceEntry { - traceId: string; - timestamp: string; - sessionId?: string; - spanCount?: string; -} - -export interface ListTracesOptions { - region: string; - runtimeId: string; - agentName: string; - limit?: number; - startTime?: number; - endTime?: number; -} - -export interface ListTracesResult { - success: boolean; - traces?: TraceEntry[]; - error?: string; -} +import { runInsightsQuery } from './insights-query'; +import type { ListTracesOptions, ListTracesResult, TraceEntry } from './types'; /** * Lists recent traces for a deployed agent by querying CloudWatch Logs Insights. @@ -33,80 +11,33 @@ export interface ListTracesResult { export async function listTraces(options: ListTracesOptions): Promise { const { region, runtimeId, limit = 20 } = options; - const client = new CloudWatchLogsClient({ - credentials: getCredentialProvider(), - region, - }); - const logGroupName = `/aws/bedrock-agentcore/runtimes/${runtimeId}-${DEFAULT_ENDPOINT_NAME}`; - const now = Date.now(); - const endTime = options.endTime ?? now; - const startTime = options.startTime ?? endTime - 12 * 60 * 60 * 1000; // default: last 12 hours - - try { - const startQuery = await client.send( - new StartQueryCommand({ - logGroupName, - startTime: Math.floor(startTime / 1000), - endTime: Math.floor(endTime / 1000), - queryString: `stats earliest(@timestamp) as firstSeen, latest(@timestamp) as lastSeen, count(*) as spanCount, earliest(attributes.session.id) as sessionId by traceId + const result = await runInsightsQuery({ + region, + logGroupName, + startTime: options.startTime, + endTime: options.endTime, + queryString: `stats earliest(@timestamp) as firstSeen, latest(@timestamp) as lastSeen, count(*) as spanCount, earliest(attributes.session.id) as sessionId by traceId | sort lastSeen desc | limit ${limit}`, - }) - ); - - if (!startQuery.queryId) { - return { success: false, error: 'Failed to start CloudWatch Logs Insights query' }; - } - - // Poll for results - let status = 'Running'; - let results: TraceEntry[] = []; - - for (let i = 0; i < 60; i++) { - await new Promise(resolve => setTimeout(resolve, 1000)); - - const queryResults = await client.send(new GetQueryResultsCommand({ queryId: startQuery.queryId })); - - status = queryResults.status ?? 'Unknown'; - - if (status === 'Complete' || status === 'Failed' || status === 'Cancelled') { - if (status !== 'Complete') { - return { success: false, error: `Query ${status.toLowerCase()}` }; - } + }); - results = (queryResults.results ?? []).map(row => { - const fields: Record = {}; - for (const field of row) { - if (field.field && field.value) { - fields[field.field] = field.value; - } - } - return { - traceId: fields.traceId ?? 'unknown', - timestamp: fields.lastSeen ?? fields.firstSeen ?? 'unknown', - sessionId: fields.sessionId, - spanCount: fields.spanCount, - }; - }); - break; - } - } + if (!result.success) { + return { success: false, error: result.error }; + } - if (status === 'Running') { - return { success: false, error: 'Query timed out after 60 seconds' }; + const traces = (result.rows ?? []).reduce((acc, row) => { + if (row.traceId) { + acc.push({ + traceId: row.traceId, + timestamp: row.lastSeen ?? row.firstSeen ?? 'unknown', + sessionId: row.sessionId, + spanCount: row.spanCount, + }); } + return acc; + }, []); - return { success: true, traces: results }; - } catch (error: unknown) { - const err = error as Error; - if (err.name === 'ResourceNotFoundException') { - return { - success: false, - error: `Log group '${logGroupName}' not found. The agent may not have been invoked yet, or traces may not be enabled.`, - }; - } - return { success: false, error: err.message ?? String(error) }; - } + return { success: true, traces }; } diff --git a/src/cli/operations/traces/types.ts b/src/cli/operations/traces/types.ts new file mode 100644 index 000000000..fae88a83b --- /dev/null +++ b/src/cli/operations/traces/types.ts @@ -0,0 +1,77 @@ +export interface CloudWatchTraceRecord { + '@timestamp': string; + '@message': unknown; + '@ptr'?: string; +} + +export interface CloudWatchSpanRecord { + traceId: string; + spanId: string; + parentSpanId?: string; + name?: string; + kind?: string; + startTimeUnixNano?: string; + endTimeUnixNano?: string; + durationNano?: string; + statusCode?: string; + serviceName?: string; + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + httpStatusCode?: number; + sessionId?: string; +} + +export interface FetchTraceRecordsOptions { + region: string; + runtimeId: string; + traceId: string; + startTime?: number; + endTime?: number; + includeSpans?: boolean; +} + +export interface FetchTraceRecordsResult { + success: boolean; + records?: CloudWatchTraceRecord[]; + spans?: CloudWatchSpanRecord[]; + error?: string; +} + +export interface GetTraceOptions { + region: string; + runtimeId: string; + agentName: string; + traceId: string; + outputPath?: string; + startTime?: number; + endTime?: number; +} + +export interface GetTraceResult { + success: boolean; + filePath?: string; + error?: string; +} + +export interface TraceEntry { + traceId: string; + timestamp: string; + sessionId?: string; + spanCount?: string; +} + +export interface ListTracesOptions { + region: string; + runtimeId: string; + agentName: string; + limit?: number; + startTime?: number; + endTime?: number; +} + +export interface ListTracesResult { + success: boolean; + traces?: TraceEntry[]; + error?: string; +}